From ad3632f12219e9ac2f47722f3651a8df8ee7fa5c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 27 Jul 2026 15:25:33 +0800 Subject: [PATCH 001/180] feat(web): add file and session references --- ...eb-command-surfaces-and-assembly.i18n.yaml | 6 +- ...07-25-web-command-surfaces-and-assembly.md | 12 +- ...25-web-command-surfaces-and-assembly.zh.md | 12 +- ...6-07-21-cross-session-references.i18n.yaml | 6 +- .../2026-07-21-cross-session-references.md | 8 +- .../2026-07-21-cross-session-references.zh.md | 8 +- ...-tui-file-reference-autocomplete.i18n.yaml | 6 +- ...6-07-23-tui-file-reference-autocomplete.md | 8 +- ...7-23-tui-file-reference-autocomplete.zh.md | 8 +- ...-web-file-and-session-references.i18n.yaml | 6 + ...6-07-27-web-file-and-session-references.md | 49 +++ ...7-27-web-file-and-session-references.zh.md | 49 +++ apps/cli/cordis.yml | 17 +- apps/cli/package.json | 6 +- apps/web/tests/slash-flow.snapshot.ts | 78 ++++- docs/capability-seams.md | 10 +- docs/config-catalog.md | 23 +- docs/cordis-catalog/events.md | 16 +- docs/cordis-catalog/services.md | 21 +- docs/event-producer-consumer.md | 20 +- docs/module-graph.md | 76 ++-- packages/client/connection/src/client/api.ts | 1 + .../client/connection/src/client/fixture.ts | 32 ++ .../client/connection/src/client/index.ts | 1 + packages/client/connection/tests/fake-api.ts | 5 + .../src/client/sessions/conversation.ts | 5 + .../src/client/sessions/fold-adapter.ts | 11 +- .../runtime/src/client/sessions/session.ts | 9 +- packages/client/runtime/tests/fake-api.ts | 5 + .../client/runtime/tests/fold-adapter.spec.ts | 33 ++ packages/client/runtime/tests/session.spec.ts | 8 +- packages/client/tsdown.client.ts | 2 +- .../client/ui-conversation/README.i18n.yaml | 6 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/chat/MessageItem.module.css | 20 +- .../src/client/chat/MessageItem.tsx | 59 +++- .../src/client/input/contract.ts | 9 +- .../src/client/input/facade.ts | 54 ++- .../ui-conversation/src/client/input/hub.ts | 44 +-- .../src/client/input/machine.ts | 35 +- .../tests/apply-inject.spec.tsx | 23 +- .../tests/chat-branch-tails.spec.tsx | 46 +++ .../ui-conversation/tests/input-bar.spec.tsx | 8 +- .../tests/input-machine.spec.ts | 20 +- .../tests/input-matrix.spec.tsx | 13 +- .../tests/input-reference-submit.spec.ts | 116 +++++++ .../tests/input-scenarios.spec.tsx | 10 +- .../ui-conversation/tests/skeleton.spec.tsx | 4 +- .../README.i18n.yaml | 6 +- packages/client/ui-reference/README.md | 25 ++ packages/client/ui-reference/README.zh.md | 25 ++ .../package.json | 11 +- .../client/ui-reference/src/client/index.ts | 115 +++++++ .../src/index.ts | 2 +- .../src/invariant.ts | 8 +- .../ui-reference/tests/browser-plugin.spec.ts | 325 ++++++++++++++++++ .../tsconfig.json | 5 +- packages/client/ui-reference/tsdown.config.ts | 3 + packages/client/ui-slash/README.i18n.yaml | 6 +- packages/client/ui-slash/README.md | 8 +- packages/client/ui-slash/README.zh.md | 8 +- packages/client/ui-slash/package.json | 2 + .../ui-slash/src/client/MenuView.module.css | 14 + .../client/ui-slash/src/client/MenuView.tsx | 44 +-- .../client/ui-slash/src/client/controller.ts | 14 +- packages/client/ui-slash/src/core/contract.ts | 7 +- packages/client/ui-slash/src/core/detect.ts | 25 +- packages/client/ui-slash/src/types.ts | 12 +- .../client/ui-slash/tests/core-detect.spec.ts | 11 + .../client/ui-slash/tests/core-menu.spec.ts | 1 + .../client/ui-slash/tests/menu-view.spec.tsx | 26 ++ packages/client/ui-slash/tsconfig.json | 3 + packages/client/ui-subagent/README.md | 31 -- packages/client/ui-subagent/README.zh.md | 31 -- .../client/ui-subagent/src/client/index.ts | 58 ---- .../client/ui-subagent/src/css-modules.d.ts | 6 - .../ui-subagent/tests/browser-plugin.spec.ts | 145 -------- packages/client/ui-subagent/tsdown.config.ts | 3 - .../file-reference-local/README.i18n.yaml | 6 + .../context/file-reference-local/README.md | 45 +++ .../context/file-reference-local/README.zh.md | 45 +++ .../context/file-reference-local/package.json | 53 +++ .../context/file-reference-local/src/index.ts | 140 ++++++++ .../file-reference-local/src/invariant.ts | 30 ++ .../file-reference-local/src/search.ts} | 86 +---- .../tests/invariant.spec.ts | 12 + .../tests/search.spec.ts} | 6 +- .../tests/service.spec.ts | 161 +++++++++ .../file-reference-local/tsconfig.json | 33 ++ .../context/file-reference/README.i18n.yaml | 6 + packages/context/file-reference/README.md | 22 ++ packages/context/file-reference/README.zh.md | 22 ++ packages/context/file-reference/package.json | 44 +++ .../context/file-reference/src/grammar.ts | 55 +++ packages/context/file-reference/src/index.ts | 51 +++ .../context/file-reference/src/invariant.ts | 30 ++ .../file-reference/tests/invariant.spec.ts | 12 + packages/context/file-reference/tsconfig.json | 21 ++ .../cordis/tool-cordis/src/api-catalog.ts | 14 + packages/core/session/package.json | 4 + packages/core/session/src/display.ts | 17 + packages/core/session/src/index.ts | 14 +- packages/host/apiproxy/README.i18n.yaml | 6 +- packages/host/apiproxy/README.md | 8 +- packages/host/apiproxy/README.zh.md | 8 +- packages/host/apiproxy/package.json | 2 + packages/host/apiproxy/src/api-proxy.ts | 151 +++++++- packages/host/apiproxy/src/api/index.ts | 3 + .../apiproxy/src/api/references.schema.ts | 42 +++ packages/host/apiproxy/src/api/references.ts | 39 +++ packages/host/apiproxy/src/api/rpc-map.ts | 3 + packages/host/apiproxy/src/api/rpc.schema.ts | 3 + packages/host/apiproxy/src/api/rpc.ts | 3 + packages/host/apiproxy/src/api/sessions.ts | 11 +- packages/host/apiproxy/src/fetch/client.ts | 15 + packages/host/apiproxy/src/fetch/handler.ts | 10 +- packages/host/apiproxy/src/index.ts | 2 + .../tests/api-proxy-references.spec.ts | 214 ++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 39 +++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 8 + packages/host/apiproxy/tsconfig.json | 6 + packages/ui/tui/package.json | 2 + packages/ui/tui/src/index.ts | 9 +- packages/ui/tui/tests/tui.spec.ts | 2 +- packages/ui/tui/tsconfig.json | 3 + pnpm-lock.yaml | 106 ++++-- scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 13 +- .../verify-package-readme-model-experience.ts | 4 +- tsconfig.base.json | 4 +- tsconfig.client.json | 2 +- tsconfig.host.json | 2 + vitest.config.ts | 2 +- 134 files changed, 2979 insertions(+), 681 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md create mode 100644 packages/client/ui-conversation/tests/input-reference-submit.spec.ts rename packages/client/{ui-subagent => ui-reference}/README.i18n.yaml (55%) create mode 100644 packages/client/ui-reference/README.md create mode 100644 packages/client/ui-reference/README.zh.md rename packages/client/{ui-subagent => ui-reference}/package.json (78%) create mode 100644 packages/client/ui-reference/src/client/index.ts rename packages/client/{ui-subagent => ui-reference}/src/index.ts (79%) rename packages/client/{ui-subagent => ui-reference}/src/invariant.ts (83%) create mode 100644 packages/client/ui-reference/tests/browser-plugin.spec.ts rename packages/client/{ui-subagent => ui-reference}/tsconfig.json (81%) create mode 100644 packages/client/ui-reference/tsdown.config.ts delete mode 100644 packages/client/ui-subagent/README.md delete mode 100644 packages/client/ui-subagent/README.zh.md delete mode 100644 packages/client/ui-subagent/src/client/index.ts delete mode 100644 packages/client/ui-subagent/src/css-modules.d.ts delete mode 100644 packages/client/ui-subagent/tests/browser-plugin.spec.ts delete mode 100644 packages/client/ui-subagent/tsdown.config.ts create mode 100644 packages/context/file-reference-local/README.i18n.yaml create mode 100644 packages/context/file-reference-local/README.md create mode 100644 packages/context/file-reference-local/README.zh.md create mode 100644 packages/context/file-reference-local/package.json create mode 100644 packages/context/file-reference-local/src/index.ts create mode 100644 packages/context/file-reference-local/src/invariant.ts rename packages/{ui/tui/src/file-autocomplete.ts => context/file-reference-local/src/search.ts} (78%) create mode 100644 packages/context/file-reference-local/tests/invariant.spec.ts rename packages/{ui/tui/tests/file-autocomplete.spec.ts => context/file-reference-local/tests/search.spec.ts} (97%) create mode 100644 packages/context/file-reference-local/tests/service.spec.ts create mode 100644 packages/context/file-reference-local/tsconfig.json create mode 100644 packages/context/file-reference/README.i18n.yaml create mode 100644 packages/context/file-reference/README.md create mode 100644 packages/context/file-reference/README.zh.md create mode 100644 packages/context/file-reference/package.json create mode 100644 packages/context/file-reference/src/grammar.ts create mode 100644 packages/context/file-reference/src/index.ts create mode 100644 packages/context/file-reference/src/invariant.ts create mode 100644 packages/context/file-reference/tests/invariant.spec.ts create mode 100644 packages/context/file-reference/tsconfig.json create mode 100644 packages/core/session/src/display.ts create mode 100644 packages/host/apiproxy/src/api/references.schema.ts create mode 100644 packages/host/apiproxy/src/api/references.ts create mode 100644 packages/host/apiproxy/tests/api-proxy-references.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml index d636aab9ff..01e9471093 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b -2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +2026-07-25-web-command-surfaces-and-assembly.md: 18ee22b8b359e7f2af15a69d2917774e8a9b609d +2026-07-25-web-command-surfaces-and-assembly.zh.md: e08c233662e16c06b3a0d352e2db9dc5e119e948 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md index 5188e8c17b..18ee22b8b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md @@ -1,10 +1,10 @@ -# Agent Note: Web command business surfaces and assembly (ui-command / ui-skill / ui-subagent) +# Agent Note: Web command business surfaces and assembly (ui-command / ui-skill / ui-reference) Status: implemented English | [中文](2026-07-25-web-command-surfaces-and-assembly.zh.md) -> Scope: the command directory cache and three-kind dispatch (ui-command), the popup selection flow, the two skill / subagent reference sources, and fixture command routing plus assembly acceptance (the slash-flow snapshot). The carrying wire lives in the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md); triggers, the menu, and the input machine live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md). +> Scope: the command directory cache and three-kind dispatch (ui-command), the popup selection flow, the skill and unified file/session reference sources, and fixture routing plus assembly acceptance (the slash-flow snapshot). The carrying wire lives in the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md); triggers, the menu, and the input machine live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md). Structured reference semantics are owned by [Web file and session references](../feature/2026-07-27-web-file-and-session-references.md). ## Problem @@ -29,7 +29,7 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx ### Reference sources (seeing only projections plus their own apply closures, on the root ctx) - **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association). -- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream). +- **ui-reference**: one `@` source starts Host-backed file and session discovery together, renders files first, keeps quoted tokens file-only, continues directory picks, and represents sessions as atomic chips backed by canonical Host mentions. Host-side snapshot preparation and failure-preserving ordinary submission are specified by the owning [reference note](../feature/2026-07-27-web-file-and-session-references.md). ### Fixture command routing and assembly @@ -38,7 +38,7 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx ### Assembly-level acceptance: the slash-flow snapshot -`apps/web/tests/slash-flow.snapshot.ts` pins the user-visible main chain (assembled keyless; package mocks are no substitute for the assembled transcript): the composer disabled with no session → creating a Workspace and entering an already-materialized blank session → picking the `/echo` leadingInput from the `/` menu → the command executes but the blank bit does not flip and the list still shows `New Session` → the first ordinary prompt's successful acceptance converts that same row; the same session-bound textarea holds across blank → active. `workspace-flow.snapshot.ts` separately pins blank-row creation/reuse, first-prompt rejection backfill, and — on a Workspace switch before the first prompt — the draft moving across input machines with the old blank row hidden. +`apps/web/tests/slash-flow.snapshot.ts` pins the user-visible main chain (assembled keyless; package mocks are no substitute for the assembled transcript): the composer disabled with no session → creating a Workspace and entering an already-materialized blank session → completing a directory and file through `@` → picking the `/echo` leadingInput from the `/` menu → the command executes but the blank bit does not flip and the list still shows `New Session` → the first ordinary prompt's successful acceptance converts that same row; the same session-bound textarea holds across blank → active. A fixture branch selects an atomic `@session` chip. `workspace-flow.snapshot.ts` separately pins blank-row creation/reuse, first-prompt rejection backfill, and — on a Workspace switch before the first prompt — the draft moving across input machines with the old blank row hidden. ## Alternatives considered @@ -47,11 +47,11 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx | Inline prompt dispatch (command text riding the message into the host for parsing) | Conflates the command and message planes; command execution being independent of the message queue is existing host semantics | | A bridge materializing skills as commands | Skills have their own directory; N registrations would be a detour; the tag form naturally avoids the command plane | | A `skill.invoke` RPC | The host has no such operation; skill references are plain text riding prompts | -| A new ContentBlock reference type | Full-chain cost (adapters/UI/compaction); text-as-truth plus structured occurrence records suffices | +| A new ContentBlock reference type | Full-chain cost (adapters/UI/compaction); canonical mention text plus atomic composer state and Host preparation preserve identity without it | | Client packages self-reporting command directories | The host is the single source of truth; the client only reads descriptors, with `commands-changed` pushing invalidation | | The `requires: 'none' \| 'agent'` discriminant axis (an agentless directory + dual-addressed queries) | With sessions always agent-backed, the amphibious command has no owner; the whole axis reverts to master's shape, to be reopened on real demand | | Dedicated commandresult / commandpanel slots | Results go through notices; the popup shell is a skeleton-internal overlay; rich result cards sit in the ledger | -| An agent-type directory as the `@` source | No type registry exists; the live-session snapshot already covers it | +| A browser-side agent directory as the `@` source | Session-reference candidates are a Host capability with stable ids and persisted source surfaces; a browser-only running-child roster cannot provide them | | A PickAction/EnterCommand class family (class-inheritance pick products) | Cross-package runtime values break client bundle purity; pure data interfaces plus closure methods are equivalent | ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md index 0134cc10cf..e08c233662 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md @@ -1,10 +1,10 @@ -# Agent Note: Web 命令业务面与装配(ui-command / ui-skill / ui-subagent) +# Agent Note: Web 命令业务面与装配(ui-command / ui-skill / ui-reference) Status: implemented [English](2026-07-25-web-command-surfaces-and-assembly.md) | 中文 -> 范围:命令目录缓存与三型判定(ui-command)、popup 选择流、skill / subagent 两个引用源、fixture 命令路由与装配验收(slash-flow 快照)。承载 wire 见[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md);触发/菜单/输入机器见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md)。 +> 范围:命令目录缓存与三型判定(ui-command)、popup 选择流、skill(技能)与统一的文件/会话引用源,以及 fixture(测试前置数据)路由与装配验收(slash-flow 快照)。承载 wire 见[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md);触发/菜单/输入机器见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md)。结构化引用语义由 [Web 文件与会话引用](../feature/2026-07-27-web-file-and-session-references.md)说明。 ## 问题 @@ -29,7 +29,7 @@ Status: implemented ### 引用源(只见投影 + 自家 apply 闭包的 root ctx) - **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。 -- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。 +- **ui-reference**:同一个 `@` source 会同时启动宿主支持的文件与会话发现,先渲染文件;带引号的 token 只显示文件;选择目录后继续补全;会话则表示为由宿主规范提及标记支撑的原子 chip。宿主侧快照准备和失败时保留内容的普通提交由对应的[引用 note](../feature/2026-07-27-web-file-and-session-references.md)定义。 ### fixture 命令路由与装配 @@ -38,7 +38,7 @@ Status: implemented ### 装配级验收:slash-flow 快照 -`apps/web/tests/slash-flow.snapshot.ts` 钉住用户可见主链(assembled keyless,包 mock 不替代装配转录):无 session 时 composer 禁用 → 创建 Workspace 并进入已实体化的 blank session → `/` 菜单选 `/echo` leadingInput → 命令执行但 blank 位不翻转、列表仍显示 `New Session` → 首条普通 prompt 成功受理后同一行转正;同一 session-bound textarea 跨 blank → active 保持。`workspace-flow.snapshot.ts` 另钉住 blank 行创建/复用、首讯拒绝回填,以及首讯前切换 Workspace 时 draft 跨 input machine 搬运且旧 blank 行隐藏。 +`apps/web/tests/slash-flow.snapshot.ts` 钉住用户可见主链(assembled keyless,包 mock 不替代装配后的 transcript(文本记录)):无 session 时 composer 禁用 → 创建 Workspace 并进入已实体化的 blank session → 通过 `@` 补全一个目录和文件 → `/` 菜单选 `/echo` leadingInput → 命令执行但 blank 位不翻转、列表仍显示 `New Session` → 首条普通 prompt 成功受理后同一行转正;同一 session-bound textarea 跨 blank → active 保持。fixture 分支会选择一个原子的 `@session` chip。`workspace-flow.snapshot.ts` 另钉住 blank 行创建/复用、首讯拒绝回填,以及首讯前切换 Workspace 时 draft 跨 input machine 搬运且旧 blank 行隐藏。 ## Alternatives considered @@ -47,11 +47,11 @@ Status: implemented | prompt 内联派发(命令文本随消息进 host 解析) | 混淆命令/消息平面;命令执行独立于消息队列是既有 host 语义 | | skill 物化为 command 的桥 | skill 自有目录;N 笔注册是绕路;标签形式天然避开命令平面 | | `skill.invoke` RPC | host 无此操作;skill 引用是随 prompt 的普通文本 | -| 新 ContentBlock 引用类型 | 全链路成本(adapter/UI/compaction);文本即真身 + 结构化 occurrence 记录已足够 | +| 新 ContentBlock 引用类型 | 全链路成本(适配器/UI/压缩);规范提及文本、原子 composer 状态与宿主准备无需该类型也能保留身份 | | client 各包自报命令目录 | host 是唯一真源;client 只读 descriptor,`commands-changed` 推失效 | | `requires: 'none' \| 'agent'` 判别轴(agentless 目录 + 双址查询) | 会话恒 agent-backed 后两栖命令无 owner;整轴回退 master 形状,待真需求重开 | | 专用 commandresult / commandpanel 坑位 | 结果走 notice;popup 壳是骨架内浮层;富结果卡入台账 | -| agent-type 目录做 `@` 源 | 无类型注册表;live-session 快照已覆盖 | +| 浏览器侧 agent 目录做 `@` 源 | 会话引用候选是宿主功能,具有稳定 id 和持久化的源表层;仅存在于浏览器中的运行中子会话 roster 无法提供这些信息 | | PickAction/EnterCommand 类族(类继承 pick 产物) | 跨包运行时值破坏 client bundle 纯度;纯数据接口 + 闭包方法等价 | ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index b1f73ed15a..64fe637064 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-21-cross-session-references.md: fc084b36e7920a72efff0f363278d24eaebc4c69 -2026-07-21-cross-session-references.zh.md: fe4a876b5265fa7ad298adf3b829bcec70e878e8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md +2026-07-21-cross-session-references.md: 0aca4606d27eab34102fef5bcd82a9565dc6599e +2026-07-21-cross-session-references.zh.md: 3f1cf4d863898bae806fab698b095fd7fd82e1f8 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index fc084b36e7..0aca4606d2 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -32,7 +32,9 @@ This preserves host driving semantics: TUI decides `send()` versus `steer()` fro ## Host adapters -TUI combines session candidates with the existing `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal. +TUI combines session candidates with the shared `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal. + +Web exposes the same candidate and preparation semantics through `reference.sessions` and `session.prompt`, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. The composer retains text and chips until preparation and enqueue succeed, then replay projects the logged display content and a compact session-source summary. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. @@ -53,8 +55,8 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, Web wire preparation, failure-preserving Web submission, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string. A keyless Web snapshot pins the assembled reference selection path. ## Consequences -The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard TUI demo bundle mounts it explicitly and exposes its count and per-source byte limits in its config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant. +The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard CLI composition mounts it explicitly for both TUI and Web and exposes its count and per-source byte limits in config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant. diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index fe4a876b52..3f1cf4d863 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -32,7 +32,9 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 宿主适配器 -TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 +TUI 把会话候选与共享的 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 + +Web 通过 `reference.sessions` 和 `session.prompt` 暴露相同的候选与准备语义,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。选择会话会创建由宿主生成的规范提及标记支撑的原子 chip。输入框会保留文本与 chip,直到准备和入队都成功;随后回放会投影日志中记录的显示内容和精简的会话来源摘要。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 @@ -53,8 +55,8 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失、Web 协议准备、失败时保留内容的 Web 提交,以及精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。无密钥 Web 快照固定装配后的引用选择路径。 ## 后果 -新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 TUI 演示组合包会显式挂载它,并在自身配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。 +新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 CLI(命令行界面)组合会为 TUI 和 Web 显式挂载它,并在配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml index 05b15028e6..c042b7168b 100644 --- a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-23-tui-file-reference-autocomplete.md: 1a136009213c845af28f4ac47a8b31d426ac8cf5 -2026-07-23-tui-file-reference-autocomplete.zh.md: 410f0d49dbd20a2dcf704892a192406020aaa86e +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md +2026-07-23-tui-file-reference-autocomplete.md: 93fd09efc826ff29531e90138939df8b3254a99a +2026-07-23-tui-file-reference-autocomplete.zh.md: 60dbfb577b11244f7dbece023333fff247cb1924 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md index 1a13600921..93fd09efc8 100644 --- a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md @@ -10,9 +10,9 @@ The TUI offered structured `@session` references but no dependable way to discov ## Decision -The TUI owns a bounded, cancellable host-workspace path index rooted at the active session's working directory. Typing `@` at a token boundary fuzzy-matches files and directories; queries containing `/` list the named directory directly, accepting a directory continues completion, and paths containing whitespace use the `@"path with spaces"` form. Configuration controls result count, index size, and excluded directory basenames. The default exclusions are `.git` and `node_modules`; traversal does not follow directory symlinks or interpret ignore files. +The shared `@deepseek-ai/dsh-file-reference-local` provider owns a bounded, cancellable host-workspace path index rooted at each active session's working directory. TUI consumes its search and grammar implementation directly, while Web reaches the same capability through the Host API as recorded in [Web file and session references](2026-07-27-web-file-and-session-references.md). Typing `@` at a token boundary fuzzy-matches files and directories; queries containing `/` list the named directory directly, accepting a directory continues completion, and paths containing whitespace use the `@"path with spaces"` form. Configuration controls result count, index size, and excluded directory basenames. The default exclusions are `.git` and `node_modules`; traversal does not follow directory symlinks or interpret ignore files. -Selecting a file changes only the editor text. The submitted user message retains the natural `@path` spelling and carries no injected contents, hidden context, or reference object. When the model-facing `read` tool is registered, the TUI contributes a stable system-prompt section that identifies `@` paths as explicit user references, directs the model to call `read` when contents are needed, and forbids claiming inspection before that call. Tool results invalidate the reusable fuzzy index so subsequent interactions observe likely workspace mutations. +Selecting a file changes only the editor text. The submitted user message retains the natural `@path` spelling and carries no injected contents, hidden context, or reference object. When the model-facing `read` tool is registered, the local provider contributes a stable system-prompt section that identifies `@` paths as explicit user references, directs the model to call `read` when contents are needed, and forbids claiming inspection before that call. Tool results invalidate the reusable fuzzy index so subsequent interactions observe likely workspace mutations. Structured session mentions keep their existing snapshot preparation. Unlike files, a referenced session has no general model-facing retrieval tool, so reducing `@session` to a path-like label would make its content unreachable. @@ -24,10 +24,10 @@ Structured session mentions keep their existing snapshot preparation. Unlike fil **Use the filesystem service's ordinary directory-list operation for discovery.** That seam is optimized for exact model-facing filesystem operations and may represent a remote namespace; recursive fuzzy indexing would multiply provider round trips and couple editor latency to tool policy. Host-side discovery keeps the terminal interaction local, while the documented namespace-alignment limitation remains explicit for non-local deployments. -**Add a new cross-package file-search capability.** The TUI is the only current consumer and the behavior is editor presentation rather than a model capability, so a new interface, implementation, and consumer package set would split the seam prematurely. +**Add a cross-package file-search capability before another consumer exists.** Rejected for the original TUI-only implementation because it would have split the seam prematurely. Web is now a second current consumer across a process boundary, so the later [Web reference decision](2026-07-27-web-file-and-session-references.md) introduces the interface / local implementation / consumer split and preserves this note's path-only semantics. ## Consequences Users can discover and insert paths without making selection itself expensive or model-visible beyond the path. The model preserves agency over whether to inspect a file, and any inspection remains reconstructable through the logged tool transcript. The fixed instruction slightly enlarges TUI system prompts when `read` is present, and content-requiring requests take an additional tool round trip. -Completion is deliberately bounded and advisory: very large workspaces may omit paths beyond the configured index cap, ignored files may still appear, and remote or virtual filesystem deployments must align the TUI host working directory with the `read` namespace or supply a different completion surface. Package tests pin token grammar, ranking, bounds, cancellation, invalidation, and path-only submission; terminal snapshots and the real Loader PTY smoke pin the visible menu and keyboard completion. +Completion is deliberately bounded and advisory: very large workspaces may omit paths beyond the configured index cap, ignored files may still appear, and remote or virtual filesystem deployments must align completion with the `read` namespace or supply a different provider. Shared-package tests pin token grammar, ranking, bounds, cancellation, invalidation, and path-only submission; terminal snapshots, the Web snapshot, and the real Loader PTY smoke pin the visible completion flows. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md index 410f0d49db..60dbfb577b 100644 --- a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md @@ -10,9 +10,9 @@ TUI 提供结构化的 `@session` 引用,但用户在编辑提示词时无法 ## 决策 -TUI 维护一个有容量上限且可取消的主机工作区路径索引,以活跃会话的工作目录为根。在 token 边界输入 `@` 会对文件和目录进行模糊匹配;查询包含 `/` 时会直接列出指定目录,接受目录后会继续补全,包含空白的路径采用 `@"path with spaces"` 形式。配置项控制结果数量、索引大小以及排除的目录基名。默认排除 `.git` 和 `node_modules`;遍历既不跟随目录符号链接,也不解析忽略文件。 +共享的 `@deepseek-ai/dsh-file-reference-local` 提供方维护一个有容量上限且可取消的宿主工作区路径索引,以每个活跃会话的工作目录为根。TUI 直接消费其搜索和语法实现,Web 则通过宿主 API 使用同一功能,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。在 token 边界输入 `@` 会对文件和目录进行模糊匹配;查询包含 `/` 时会直接列出指定目录,接受目录后会继续补全,包含空白的路径采用 `@"path with spaces"` 形式。配置项控制结果数量、索引大小以及排除的目录基名。默认排除 `.git` 和 `node_modules`;遍历既不跟随目录符号链接,也不解析忽略文件。 -选择文件只会改变编辑器文本。提交的用户消息保留自然的 `@path` 写法,不携带注入的内容、隐藏上下文或引用对象。注册面向模型的 `read` 工具时,TUI 会加入一个稳定的系统提示词段,说明 `@` 路径是用户的显式引用,指示模型在需要内容时调用 `read`,并禁止模型在调用前声称已检查文件。工具结果会使可复用的模糊索引失效,后续交互因而能看到工作区中可能发生的变更。 +选择文件只会改变编辑器文本。提交的用户消息保留自然的 `@path` 写法,不携带注入的内容、隐藏上下文或引用对象。注册面向模型的 `read` 工具时,本地提供方会加入一个稳定的系统提示词段,说明 `@` 路径是用户的显式引用,指示模型在需要内容时调用 `read`,并禁止模型在调用前声称已检查文件。工具结果会使可复用的模糊索引失效,后续交互因而能看到工作区中可能发生的变更。 结构化会话提及保留现有的快照准备方式。与文件不同,被引用的会话没有通用的模型侧检索工具;如果把 `@session` 简化为类似路径的标签,模型将无法获取其内容。 @@ -24,10 +24,10 @@ TUI 维护一个有容量上限且可取消的主机工作区路径索引,以 **使用文件系统服务的常规目录列表操作进行发现。** 该 seam 针对面向模型的准确文件系统操作进行了优化,并且可能表示远程命名空间;递归模糊索引会增加提供方往返次数,并使编辑器延迟与工具策略耦合。主机侧发现让终端交互保留在本地,同时文档仍明确说明非本地部署中的命名空间对齐限制。 -**新增跨包的文件搜索功能。** TUI 是目前唯一的消费方,而且该行为属于编辑器呈现而非模型功能;新增一组接口、实现和消费方包会过早拆分这条 seam。 +**在出现另一个消费方之前新增跨包的文件搜索功能。** 原始实现只有 TUI 消费,因此不予采纳:该方案会过早拆分这条 seam。Web 现已成为跨进程边界的第二个当前消费方,因此后续的 [Web 引用决策](2026-07-27-web-file-and-session-references.md)引入接口/本地实现/消费方拆分,并保留本记录仅使用路径的语义。 ## 影响 用户可以发现并插入路径,而选择操作本身不会带来高开销,对模型可见的内容也仅限路径。模型仍可自行决定是否检查文件,任何检查都能通过已记录的工具 transcript 重建。存在 `read` 时,固定指令会略微增大 TUI 系统提示词;需要文件内容的请求还会增加一次工具往返。 -补全有意采用有界的提示性设计:超大型工作区可能省略超过配置索引上限的路径,被忽略的文件仍可能出现,远程或虚拟文件系统部署必须让 TUI 的主机工作目录与 `read` 命名空间对齐,否则需要提供不同的补全接口。包(package)测试固定 token 语法、排序、边界、取消、失效和仅提交路径的行为;终端快照与真实 Loader PTY 冒烟测试固定可见菜单和键盘补全。 +补全有意采用有界的提示性设计:超大型工作区可能省略超过配置索引上限的路径,被忽略的文件仍可能出现,远程或虚拟文件系统部署必须让补全与 `read` 命名空间对齐,否则需要提供不同的提供方。共享包(package)测试固定 token 语法、排序、边界、取消、失效和仅提交路径的行为;终端快照、Web 快照与真实 Loader PTY 冒烟测试固定可见的补全流程。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml new file mode 100644 index 0000000000..0c8290a540 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +2026-07-27-web-file-and-session-references.md: 71648a6ddbcffc7e700db1b0ce2135bf157e2bab +2026-07-27-web-file-and-session-references.zh.md: 05c0896700ae55c64b94c0b37a00a38127c97f1f diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md new file mode 100644 index 0000000000..71648a6ddb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -0,0 +1,49 @@ +# Agent Note: Web file and session references + +Status: implemented + +English | [中文](2026-07-27-web-file-and-session-references.zh.md) + +## Problem + +The Web composer had a reusable slash/reference trigger pipeline, but its `@` source was inert subagent-label text. The TUI already offered workspace-path discovery and structured cross-session snapshots, so Web needed the same user semantics without scanning the Host filesystem in the browser, binding session identity to a display label, or clearing a draft before Host-side snapshot preparation succeeded. + +## Decision + +Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Host lookups concurrently and preserves the TUI ordering of files before sessions; non-selectable `文件与文件夹` and `Session 对话` headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other. + +The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the Host RPC. The TUI imports the same search and grammar implementation instead of retaining a private copy. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash. + +A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. `session.prompt` parses those mentions and calls `ctx.sessionReferences.prepare()` before enqueue, then passes the prepared content and contexts in one agent operation. Invalid mentions, cancellation, missing capability, source-read failure, and budget failure enqueue nothing. + +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or RPC failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders each metadata-confirmed session label as a reference chip even when following text is adjacent, plus a compact session-source summary instead of the snapshot JSON baked into model content. + +## Reference transaction + +```text +type @ → parallel file/session RPCs → pick path text or canonical session chip + → serialize draft → Host parses and prepares all sessions → enqueue once + ↘ any pre-enqueue failure: retain the unchanged editable draft +``` + +File lookup is advisory and cancellable; selection itself performs no read. Session preparation is authoritative and all-or-nothing because the source snapshot must be fixed before the target inbox accepts the message. + +## Alternatives considered + +**Keep file completion TUI-private.** Rejected after Web became a second current consumer; duplicate grammar, ranking, bounds, and invalidation would drift, while browser-side code cannot safely access the Host workspace. + +**Scan files through ordinary filesystem-tool RPCs.** Rejected because recursive fuzzy discovery is editor latency work, not a model-facing exact filesystem operation, and would couple the menu to tool policy and provider round trips. + +**Eagerly attach selected file contents.** Rejected because selection would spend context before relevance is known and bypass the logged, auditable `read` call/result sequence. + +**Represent sessions as plain `@label` text.** Rejected because labels are neither stable nor unique and cannot identify the source snapshot. Canonical Host-produced mentions preserve opaque session identity while keeping a readable display. + +**Clear the composer before the RPC settles.** Rejected because a failed preparation would lose the only editable copy of the request and visually claim acceptance that never occurred. + +## Verification + +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-text reference projection, codec round-trip, Host wire validation, all-or-nothing prompt preparation, and draft retention across serialization and RPC failures. The keyless assembled Web snapshot renders the available reference sections, selects a directory and file, then selects a session reference through the real client composition. + +## Consequences + +Web and TUI now share `@file` discovery semantics and the same structured session-reference identity, while Host services remain the authority for filesystem and session access. The new file-reference seam adds two packages and one Host RPC domain, but keeps browser bundles free of Node APIs and permits another provider to align completion with a remote filesystem. Candidate lookup failures remain quiet menu degradation; submission failures remain explicit and recoverable. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md new file mode 100644 index 0000000000..05c0896700 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Web 文件与会话引用 + +Status: implemented + +[English](2026-07-27-web-file-and-session-references.md) | 中文 + +## 问题 + +Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 `@` source 只是不会产生实际作用的 subagent 标签文本。TUI 已经提供工作区路径发现和结构化跨会话快照,因此 Web 需要提供相同的用户语义,同时避免在浏览器中扫描宿主文件系统、把会话身份绑定到显示标签,或者在宿主侧快照准备成功前清除草稿。 + +## 决策 + +Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项宿主查询,并保留 TUI 中文件排在会话之前的顺序;不可选择的 `文件与文件夹` 和 `Session 对话` 标题会区分两个连续的候选分组,且不会进入键盘选择索引。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。 + +文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费宿主 RPC。TUI 直接导入同一套搜索和语法实现,不再保留私有副本。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。 + +选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` 提及标记。`session.prompt` 会解析这些提及标记,并在入队前调用 `ctx.sessionReferences.prepare()`,随后以一次 agent 操作传入准备后的内容和上下文。无效提及标记、取消、功能缺失、读取源会话失败和预算失败都不会让消息入队。 + +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 RPC 失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把元数据确认的每个会话标签渲染为引用 chip,即使后续文本与标签直接相邻也如此,并显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 + +## 引用事务 + +```text +type @ → parallel file/session RPCs → pick path text or canonical session chip + → serialize draft → Host parses and prepares all sessions → enqueue once + ↘ any pre-enqueue failure: retain the unchanged editable draft +``` + +文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备具有权威性,并且必须全有或全无,因为目标收件箱接受消息前必须固定源快照。 + +## 备选方案 + +**文件补全仅保留在 TUI 内部。** Web 成为第二个当前消费方后不予采纳:重复的语法、排序、边界和失效处理会产生偏差,而且浏览器侧代码无法安全访问宿主工作区。 + +**通过普通文件系统工具 RPC 扫描文件。** 不予采纳,因为递归模糊发现属于编辑器低延迟工作,而不是面向模型的精确文件系统操作;该方案还会把菜单与工具策略及提供方往返绑定。 + +**选择文件时立即附加其内容。** 不予采纳,因为该方案会在尚未确定相关性时消耗上下文,并绕过可从日志重建、可审计的 `read` 调用/结果序列。 + +**用普通 `@label` 文本表示会话。** 不予采纳,因为标签既不稳定也不唯一,无法标识源快照。宿主生成的规范提及标记既能保留不透明会话身份,也能保持显示内容易读。 + +**RPC 完成前清空输入框。** 不予采纳,因为准备失败会丢失请求唯一可编辑的副本,并在视觉上错误表示一个从未成功的接受操作。 + +## 验证 + +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻文本条件下的引用投影、codec 无损往返、宿主协议校验、全有或全无的提示词准备,以及在序列化和 RPC 失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择目录、文件和会话引用。 + +## 后果 + +Web 与 TUI 现在共享 `@file` 发现语义和同一套结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。新的文件引用 seam 增加了两个包和一个宿主 RPC 领域,但浏览器 bundle 中不包含 Node API,并允许其他提供方让补全与远程文件系统对齐。候选查询失败仍会让菜单静默降级;提交失败仍会显式报告且可恢复。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index efd75c1cf5..9b450539d0 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,6 +89,17 @@ config: root: './.sessions' +- id: session-query-sqlite + name: '@deepseek-ai/dsh-session-query-sqlite' + config: + path: './.sessions/session-query.db' + +- id: session-reference + name: '@deepseek-ai/dsh-session-reference' + +- id: file-reference-local + name: '@deepseek-ai/dsh-file-reference-local' + - id: storage name: '@deepseek-ai/dsh-storage' @@ -287,7 +298,7 @@ name: '@deepseek-ai/dsh-client-ui-workspace' # Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over -# it (ui-command), and the two reference sources (ui-skill / ui-subagent). +# it (ui-command), and the two reference sources (ui-skill / ui-reference). - id: ui-slash name: '@deepseek-ai/dsh-client-ui-slash' @@ -297,8 +308,8 @@ - id: ui-skill name: '@deepseek-ai/dsh-client-ui-skill' -- id: ui-subagent - name: '@deepseek-ai/dsh-client-ui-subagent' +- id: ui-reference + name: '@deepseek-ai/dsh-client-ui-reference' - id: ui-question name: '@deepseek-ai/dsh-client-ui-question' diff --git a/apps/cli/package.json b/apps/cli/package.json index e0a3a51c94..1b1da98ca7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -36,7 +36,7 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", - "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", + "@deepseek-ai/dsh-client-ui-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", @@ -46,6 +46,8 @@ "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -55,6 +57,8 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/apps/web/tests/slash-flow.snapshot.ts b/apps/web/tests/slash-flow.snapshot.ts index 29c1d68f7a..8dea83e20b 100644 --- a/apps/web/tests/slash-flow.snapshot.ts +++ b/apps/web/tests/slash-flow.snapshot.ts @@ -2,13 +2,13 @@ // Assembled keyless snapshot of the slash/input/session convergence under the // agent-parity model: the New Session view state locks the composer until a // Workspace is picked (connectWorkspace materializes the full Session+Agent), -// the '/' menu serves the session's wire command catalog (sessions are always -// agent-backed — no draft/materialized split), a leadingInput command claims, -// submits over the wire, and notices its result, and the SAME composer -// textarea then carries the first plain send, whose ACCEPTANCE (not attempt) -// flips blank and surfaces the session in lists. This is the user-visible -// acceptance anchor — package mocks do not substitute for the assembled -// application transcript. +// the '@' menu descends a Host-backed file directory, the '/' menu serves the +// session's wire command catalog (sessions are always agent-backed — no +// draft/materialized split), a leadingInput command claims, submits over the +// wire, and notices its result, and the SAME composer textarea then carries +// the first plain send, whose ACCEPTANCE (not attempt) flips blank and +// surfaces the session in lists. This is the user-visible acceptance anchor — +// package mocks do not substitute for the assembled application transcript. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -27,7 +27,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] }, { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] }, - { id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] }, + { id: '@deepseek-ai/dsh-client-ui-reference', dir: 'ui-reference', url: '/plugins/ui-reference.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-slash'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', @@ -114,7 +114,7 @@ async function typeComposer(composer: HTMLTextAreaElement, value: string): Promi await waitFor(() => { expect(composer.value).toBe(value) }) } -it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => { +it('locked view state, connectWorkspace unlock, @file and /echo chains, and blank-on-acceptance ride one resident composer', async () => { boot('?fixture=empty') // View state: no session entity — the composer renders locked; only the @@ -141,6 +141,23 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on- ) expect(composer.disabled).toBe(false) + // '@' combines Host-backed references. Picking a directory keeps + // completion open at its trailing slash; picking a file closes it with a + // separator so ordinary prompt text can continue. + await typeComposer(composer, '@') + const referenceMenu = await screen.findByRole('listbox', { name: 'Trigger suggestions' }) + await waitFor(() => { expect(visibleText(referenceMenu)).toContain('Folder · notes/') }) + const referenceSections = [ + within(referenceMenu).getByText('文件与文件夹').textContent, + ] + fireEvent.mouseDown(screen.getByRole('option', { name: /Folder · notes\// })) + await waitFor(() => { expect(composer.value).toBe('@notes/') }) + const nestedFile = await screen.findByRole('option', { name: /File · demo\.txt/ }) + fireEvent.mouseDown(nestedFile) + await waitFor(() => { expect(composer.value).toBe('@notes/demo.txt ') }) + const filePathCompleted = composer.value + await typeComposer(composer, '') + // '/' opens the menu with the session's wire command catalog (the session // is agent-backed from birth — the catalog is the single-address list). await typeComposer(composer, '/') @@ -178,14 +195,57 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on- expect({ menuHadEcho: menuText.includes('echo'), menuHadCompact: menuText.includes('compact'), + referenceSections, + filePathCompleted, composerSurvivedConversion: after === before, sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!), }).toMatchInlineSnapshot(` { "composerSurvivedConversion": true, + "filePathCompleted": "@notes/demo.txt ", "menuHadCompact": true, "menuHadEcho": true, + "referenceSections": [ + "文件与文件夹", + ], "sessionListed": "nova1 session", } `) }) + +it('the assembled @ menu inserts a session candidate as one atomic chip', async () => { + boot('?fixture') + const composer = await screen.findByPlaceholderText( + 'Describe what you want to build', {}, { timeout: 10_000 }, + ) + await typeComposer(composer, '@') + const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' }) + await waitFor(() => { + expect(visibleText(menu)).toContain('Session · Fixture child session') + }) + const referenceSections = [ + within(menu).getByText('文件与文件夹').textContent, + within(menu).getByText('Session 对话').textContent, + ] + fireEvent.mouseDown(screen.getByRole('option', { name: /Session · Fixture child session/ })) + await waitFor(() => { + expect(composer.value).toBe('\uFFFC') + }) + const chip = document.querySelector('[data-decoration="chip"]') + expect({ + atomicDraftLength: composer.value.length, + chipLabel: chip?.title, + menuClosed: screen.queryByRole('listbox', { name: 'Trigger suggestions' }) === null, + referenceSections, + }).toMatchInlineSnapshot(` + { + "atomicDraftLength": 1, + "chipLabel": "@Fixture child session", + "menuClosed": true, + "referenceSections": [ + "文件与文件夹", + "Session 对话", + ], + } + `) +}) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2da9652c59..2e49ce9289 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -47,6 +47,9 @@ flowchart LR svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] + pkg_file_reference["file-reference"] + svc_fileReferences["ctx.fileReferences
Workspace file-reference discovery"] + pkg_file_reference_local["file-reference-local"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] pkg_tui["tui"] pkg_session_title["session-title"] @@ -152,6 +155,8 @@ flowchart LR pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_compact_tool_result_prune --> svc_toolResultPrune + pkg_file_reference --> svc_fileReferences + pkg_file_reference_local --> svc_fileReferences pkg_fs --> svc_fs pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs @@ -224,6 +229,7 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_commands --> pkg_tui svc_compact --> pkg_compact_basic + svc_fileReferences --> pkg_apiproxy svc_fs --> pkg_tool_fs svc_httpServer --> pkg_connection svc_httpServer --> pkg_hmr @@ -248,6 +254,7 @@ flowchart LR svc_sessionPersistence --> pkg_tool_bash svc_sessionQuery --> pkg_session_reference svc_sessionQuery --> pkg_tool_session_query + svc_sessionReferences --> pkg_apiproxy svc_sessionReferences --> pkg_tui svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop @@ -305,7 +312,8 @@ flowchart LR | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | -| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | `apiproxy` | - | The local provider owns one invalidated path index per agent; Host RPC projects its cancellable path candidates to browser reference sources. | +| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | `apiproxy`, [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; Host and TUI adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `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/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6a31c353fd..2a989012ed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -380,6 +380,24 @@ export interface ToolResultPruneConfig { Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../packages/compact/compact-tool-result-prune/src/types.ts) +## `@deepseek-ai/dsh-file-reference-local` + +Requires: `agents` + +```ts config-catalog +/** Local file-reference discovery configuration. */ +export interface Config { + /** Maximum ranked candidates returned for one query. */ + maxResults?: number + /** Maximum indexed files and directories per agent workspace. */ + maxEntries?: number + /** Directory basenames never traversed or offered. */ + excludedDirectories?: string[] +} +``` + +Source: [`packages/context/file-reference-local/src/index.ts:35`](../packages/context/file-reference-local/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` ```ts config-catalog @@ -1781,7 +1799,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:269`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` @@ -2052,12 +2070,12 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slash` ([`packages/client/ui-slash/src/index.ts`](../packages/client/ui-slash/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) @@ -2086,6 +2104,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@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)) +- `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ccae4c9f9b..dc24ca01cd 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -641,7 +641,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:71`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -662,7 +662,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:89`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:81`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -685,7 +685,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -706,7 +706,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/src/index.ts) ## `slash/*` @@ -726,7 +726,7 @@ Applies one command claim to the scoped Input. Dispatched with the session's sco 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:228`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-consume-token` — bail @@ -742,7 +742,7 @@ Consumes one command token after business success (popup settle / menu-pick exec 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-insert-reference` — bail @@ -758,7 +758,7 @@ Inserts one reference into the scoped Input (same carrier routing and applied-tr 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:235`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-insert-text` — bail @@ -775,7 +775,7 @@ Replaces the trigger token span with literal text — the plain-text reference p 'slash/input-insert-text'(request: InsertTextRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:250`](../../packages/client/ui-slash/src/types.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cc21210f3b..654bd30b74 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -469,6 +469,25 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts) +## `ctx.fileReferences` — `FileReferenceService` (abstract seam) + +Host capability for cancellable file-reference discovery. + +```ts cordis-catalog +/** + * List file and directory candidates for one agent's working directory. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/context/file-reference/src/index.ts:32`](../../packages/context/file-reference/src/index.ts) + ## `ctx.fs` — `FileSystem` (abstract seam) Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. @@ -1342,7 +1361,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:598`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ced431bf51..63b41bfe0e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,8 +9,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`file-reference-local`](../packages/context/file-reference-local), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | @@ -34,14 +34,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../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:79`](../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), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../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-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../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), [`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-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `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), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`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-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), [`file-reference-local`](../packages/context/file-reference-local), [`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-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:228`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:235`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:250`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../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:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 69bcdfd4ee..4a09034bac 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -147,13 +147,13 @@ flowchart TD pkg_client_ui_models["client-ui-models"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] + pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slash["client-ui-slash"] pkg_client_ui_slots["client-ui-slots"] - pkg_client_ui_subagent["client-ui-subagent"] pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_trajectory["client-ui-trajectory"] pkg_client_ui_workspace["client-ui-workspace"] @@ -165,6 +165,8 @@ flowchart TD pkg_code_runtime_worker["code-runtime-worker"] end subgraph group_context["packages/context"] + pkg_file_reference["file-reference"] + pkg_file_reference_local["file-reference-local"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_workspace_context["workspace-context"] @@ -271,9 +273,6 @@ flowchart TD 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_runtime - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots @@ -304,26 +303,12 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants 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_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_theme --> pkg_client_locale pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives @@ -381,13 +366,6 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> 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 @@ -470,6 +448,8 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm + pkg_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -543,6 +523,10 @@ flowchart TD pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session pkg_permission --> pkg_user_approval + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_file_reference + pkg_client_ui_slash --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -675,6 +659,26 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_reference --> pkg_client_connection + pkg_client_ui_reference --> pkg_client_runtime + pkg_client_ui_reference --> pkg_client_ui_slash + pkg_client_ui_reference --> pkg_file_reference + pkg_client_ui_reference --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants + pkg_file_reference_local --> pkg_agent + pkg_file_reference_local --> pkg_file_reference + pkg_file_reference_local --> pkg_invariants + pkg_file_reference_local --> pkg_system_prompt + pkg_file_reference_local --> pkg_tools pkg_workspace_context --> pkg_agent pkg_workspace_context --> pkg_fs pkg_workspace_context --> pkg_invariants @@ -751,6 +755,7 @@ flowchart TD pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_commands + pkg_tui --> pkg_file_reference_local pkg_tui --> pkg_goal pkg_tui --> pkg_invariants pkg_tui --> pkg_llm @@ -765,6 +770,13 @@ flowchart TD pkg_tui --> pkg_token_meter pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_goal @@ -882,7 +894,6 @@ flowchart TD | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`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-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-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`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) | | [`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) | @@ -894,10 +905,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` | [`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-settings-general`](../packages/client/ui-settings-general) | `client` | [`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), [`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) | | [`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) | | [`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) | @@ -916,7 +924,6 @@ flowchart TD | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`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-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-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) | | [`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) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | @@ -938,6 +945,7 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope) | | [`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) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | @@ -954,6 +962,7 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`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), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`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) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | @@ -975,6 +984,10 @@ 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) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`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-reference`](../packages/client/ui-reference) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`file-reference`](../packages/context/file-reference), [`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) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`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) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -987,7 +1000,8 @@ flowchart TD | [`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) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`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) | | [`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) | diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index edc5b2e25d..effeca6f8e 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -10,6 +10,7 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, + ReferencesApi, FileReferenceItem, SessionReferenceItem, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index eaab0a43f9..a09a9a75a8 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -798,6 +798,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }) }, }, + references: { + files: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const query = request.payload.query.toLocaleLowerCase() + const items = [ + { path: 'notes', kind: 'directory' as const }, + { path: 'README.md', kind: 'file' as const }, + { path: 'notes/demo.txt', kind: 'file' as const }, + ].filter(item => item.path.toLocaleLowerCase().includes(query)) + return ok(request, { items }) + }, + sessions: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const query = request.payload.query.toLocaleLowerCase() + const items = sessions + .filter(item => item.sessionId !== request.payload.sessionId) + .filter(item => String(item.sessionId).toLocaleLowerCase().includes(query) + || item.cwd?.toLocaleLowerCase().includes(query) === true) + .map(item => ({ + sessionId: item.sessionId, + label: item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId), + ...item.cwd === undefined ? {} : { cwd: item.cwd }, + createdAt: item.updatedAt, + mention: `@[${item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId)}](dsh-session:${btoa(JSON.stringify(item.sessionId)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '')})`, + })) + return ok(request, { items }) + }, + }, events: { async *mux(_request, signal) { const conn = new FxInbox() @@ -919,6 +949,8 @@ export class FixtureApiClient extends AbstractApiClient { // The in-memory execute never blocks, so a never-aborting signal is faithful here. case 'command.execute': return this.api.commands.execute(request, new AbortController().signal) case 'skill.list': return this.api.skills.list(request) + case 'reference.files': return this.api.references.files(request) + case 'reference.sessions': return this.api.references.sessions(request) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index d4505eb659..f249153287 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -15,6 +15,7 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, + ReferencesApi, FileReferenceItem, SessionReferenceItem, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bf7295cc50..7c365f88c0 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -104,6 +104,11 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } + readonly references: IApiClient['references'] = { + files: (payload: unknown) => this.record('reference.files', payload, Promise.resolve(ok({ items: [] }))), + sessions: (payload: unknown) => this.record('reference.sessions', payload, Promise.resolve(ok({ items: [] }))), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 49ae8634ec..f4ddf6c5b5 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 { PromptPrefixContext } from '@deepseek-ai/dsh-session/types' import type { RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' @@ -48,6 +49,8 @@ export interface UserMessageNode { time: number content: readonly ContentBlock[] source: unknown + /** Model-hidden descriptors for contexts baked ahead of this direct prompt. */ + prefixContexts?: readonly PromptPrefixContext[] } /** A finalized (or interruption-frozen) assistant message. */ @@ -74,6 +77,8 @@ export interface SteeringMessageNode { turn: number content: readonly ContentBlock[] source: unknown + /** Model-hidden descriptors for contexts baked ahead of this direct prompt. */ + prefixContexts?: readonly PromptPrefixContext[] } /** A context/system injection surfaced in the flow. */ diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 0f40d9bf2a..1a9a983b93 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -8,6 +8,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' // go through it — the package root points at lib/index.js (needs a build) which the vite // browser bundle cannot resolve; surface.ts has no Node dependencies. import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' +import { displayPromptContent } from '@deepseek-ai/dsh-session/display' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' @@ -51,7 +52,10 @@ function materializeNode( } return { kind: 'user', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, + content: displayPromptContent(event.data), source: event.data.source, + ...event.data.envelope === undefined + ? {} + : { prefixContexts: event.data.envelope.prefixContexts }, } case 'assistant/message': return { @@ -62,7 +66,10 @@ function materializeNode( case 'steering/message': return { kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, - content: event.data.content, source: event.data.source, + content: displayPromptContent(event.data), source: event.data.source, + ...event.data.envelope === undefined + ? {} + : { prefixContexts: event.data.envelope.prefixContexts }, } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 75c55bc4bd..6a701f391d 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -173,9 +173,14 @@ 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 mode - queue appends after the current turn; steer interrupts it. + * @param signal - optional cancellation for Host-side pre-enqueue preparation. * @returns the prompt result (also mirrored into promptError on failure). */ - async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> { + async prompt( + content: ContentBlock[], + mode: 'queue' | 'steer', + signal?: AbortSignal, + ): Promise> { this.promptError = null this.lastAgentError = null // Synchronous, before the first await: the blank → engaging edge must be @@ -185,7 +190,7 @@ export class Session implements ObservableSnapshot { this.notifier.markDirty() let result: RpcResult<{ accepted: true }> try { - result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result + result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content }, signal)).result } catch (error) { result = transportError(error) } diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index dcb334f6ea..42c1e8256c 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -126,6 +126,11 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } + readonly references: IApiClient['references'] = { + files: (payload: unknown) => this.record('reference.files', payload, Promise.resolve(ok({ items: [] }))), + sessions: (payload: unknown) => this.record('reference.sessions', payload, Promise.resolve(ok({ items: [] }))), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index bb360e2a67..dab719cc4e 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -55,6 +55,39 @@ describe('FoldAdapter', () => { expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false }) }) + it('replays only the direct prompt while retaining referenced-session descriptors', () => { + const adapter = new FoldAdapter() + const prefixContexts = [{ + source: { kind: 'plugin', plugin: 'session-reference' }, + meta: { + kind: 'session-reference', + version: 1, + references: [{ sessionId: 'source', label: 'Research' }], + }, + }] + adapter.reset([at(0, { + type: 'user/message', + surfaceOp: 'append', + data: { + content: [ + { type: 'text', text: 'snapshot' }, + { type: 'text', text: '\n\n## My request:\n' }, + { type: 'text', text: 'compare @Research' }, + ], + source: { kind: 'user' }, + envelope: { + displayContent: [{ type: 'text', text: 'compare @Research' }], + prefixContexts, + }, + }, + })], 0) + expect(adapter.nodes().nodes[0]).toMatchObject({ + kind: 'user', + content: [{ type: 'text', text: 'compare @Research' }], + prefixContexts, + }) + }) + it('returns call:null for a tool-result whose call fell outside the window', () => { const adapter = new FoldAdapter() adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index f5cf9a2138..d0fb4b53a7 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -219,17 +219,23 @@ describe('paging', () => { describe('prompt and cancel errors', () => { it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => { const { api, session } = makeSession() + const prompt = vi.spyOn(api.sessions, 'prompt') + const controller = new AbortController() // The blank → engaging edge fires before the RPC settles: the first-send // flow reads the phase on the session area's first frame to keep the // guidance hero from flashing back in. expect(session.getSnapshot().composerPhase).toBe('blank') - const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue') + const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue', controller.signal) expect(session.getSnapshot().composerPhase).toBe('engaging') const result = await inFlight expect(result.ok).toBe(true) // Monotone: settlement alone does not step the phase anywhere. expect(session.getSnapshot().composerPhase).toBe('engaging') expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }]) + expect(prompt).toHaveBeenCalledWith( + { sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }, + controller.signal, + ) // First content lands (running turn): engaging → active. session.handleRunning(true) expect(session.getSnapshot().composerPhase).toBe('active') diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9b93feae8b..766affc4b2 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -27,7 +27,7 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|file-reference|session|llm|tools|brand)(\/|$)/ /** * Documented TEMPORARY exemption, not a platform module (hence not in diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 8117d42ea4..b1440226e7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467 -README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311 +# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md +README.md: 8c807d8ab9c9fa4559c846876a99c373366369de +README.zh.md: 13ee84a45ecbba3c950beb4478ecb80b15b47032 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b9ec555f15..8c807d8ab9 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,6 +14,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. +Ordinary submission is a transaction between the input machine and its default sink. The composer retains its draft and atomic reference chips while serialization or `session.prompt` is pending, clears them only after Host acceptance, and restores the editable phase unchanged after rejection. Replay uses the session package's display projection for prompt envelopes; session-reference metadata projects its confirmed label as a reference chip even when following prompt text is adjacent, and adds a compact `引用会话` source summary below the direct user text instead of exposing the prepared snapshot JSON. + `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). ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index b1e3c1f433..13ee84a45e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,6 +14,8 @@ 逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。 +普通提交是输入状态机与默认 sink 之间的一项事务。在序列化或 `session.prompt` 等待完成期间,输入框会保留草稿和原子引用 chip;只有宿主接受后才会将它们清除,拒绝后则原样恢复可编辑阶段。回放对提示词封套使用会话包的显示投影;会话引用元数据会把已确认的标签投影为引用 chip,即使后续提示词文本与标签直接相邻也如此,并在直接用户文本下方添加精简的 `引用会话` 来源摘要,而不会暴露准备好的快照 JSON。 + `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/*` 子路径获取它们)。 ## 模型体验 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 047878f1d0..41e7cd3071 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,17 @@ justify-content: flex-end; } +.userStack { + display: flex; + max-width: min(525px, 82%); + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + .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. */ @@ -19,6 +27,12 @@ color: var(--dsw-alias-label-primary); } +.referenceSummary { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + .badge { display: inline-block; margin-bottom: 4px; @@ -33,8 +47,8 @@ padding: 2px 0; } -/* Reference chip projection inside a user bubble (`name` model - spans render as chips; free geometry — no textarea pairing here). */ +/* Reference-chip projection inside a user bubble; free geometry means the + textarea overlay's metric pairing does not apply here. */ .refChip { display: inline-block; margin: 0 2px; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index e79304fc19..bb935b62b3 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -27,17 +27,21 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown } /** - * Display projection of reference forms in a user bubble (free geometry — no - * textarea alignment constraint here); everything else stays plain text. The - * logged model text remains the single truth; this is presentation only. Two - * shapes decorate: legacy `name` spans (pre-decision-21 - * history) and plain-text `/name` / `@name` word-boundary tokens (decision - * 21: the sent text IS the reference — the bubble uses the same plainest - * token scan as the composer, minus the lexicon: sent tokens were validated - * at compose time, so shape alone decorates). + * Decorate legacy skill spans, boundary-delimited plain references, and exact + * metadata-confirmed session labels in the user bubble. Confirmed labels may + * touch following prompt text because their durable metadata disambiguates + * the reference boundary. Logged message text remains unchanged. */ -function projectUserText(text: string): ReactNode { - const re = /([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g +function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode { + const exactSessions = [...new Set(sessionLabels)] + .filter(label => label.length > 0) + .sort((left, right) => right.length - left.length) + .map(label => label.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')) + const sessionPattern = exactSessions.length === 0 ? '' : `@(?:${exactSessions.join('|')})|` + const re = new RegExp( + `([^<]+)|(^|\\s)(${sessionPattern}[/@][\\w-]+(?=\\s|$))`, + 'gu', + ) const parts: ReactNode[] = [] let cursor = 0 let m: RegExpExecArray | null @@ -47,7 +51,7 @@ function projectUserText(text: string): ReactNode { const label = legacy ? `/${m[1]}` : m[3] ?? '' if (tokenStart > cursor) parts.push() parts.push( - + {label} , ) @@ -58,17 +62,42 @@ function projectUserText(text: string): ReactNode { return <>{parts} } +function referencedSessionLabels( + node: UserMessageNode | SteeringMessageNode, +): string[] { + const labels: string[] = [] + for (const context of node.prefixContexts ?? []) { + const meta = context.meta + if (typeof meta !== 'object' || meta === null || Array.isArray(meta) + || meta.kind !== 'session-reference' || !Array.isArray(meta.references)) continue + for (const reference of meta.references) { + if (typeof reference !== 'object' || reference === null || Array.isArray(reference)) continue + const label = typeof reference.label === 'string' + ? reference.label + : typeof reference.sessionId === 'string' ? reference.sessionId : undefined + if (label !== undefined) labels.push(label) + } + } + return labels +} + export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { switch (node.kind) { case 'user': case 'steering': { const { text, rest } = contentText(node.content) + const referencedSessions = referencedSessionLabels(node) return (
-
- {node.kind === 'steering' && 插话} - {projectUserText(text)} - {rest.map((block, i) => )} +
+
+ {node.kind === 'steering' && 插话} + {projectUserText(text, referencedSessions)} + {rest.map((block, i) => )} +
+ {referencedSessions.length > 0 + ?
引用会话 · {referencedSessions.join(', ')}
+ : null}
) diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 8a4d2905db..ac4f216a8e 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -211,7 +211,7 @@ export interface InputState { export interface SubmitAttempt { readonly seq: number readonly signal: AbortSignal - /** Draft at enter time; rollback restores it only while the live draft still equals it. */ + /** Draft at enter time; settlement clears it only after acceptance. */ readonly draftSnapshot: string } @@ -250,11 +250,6 @@ export type InputEvent = | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } - /** - * An ordinary (default-sink) send was accepted: clear the draft as a COMMIT — - * undo must not resurrect sent content (mirrors submit-settled's success arm). - */ - | { readonly type: 'send-committed' } | { readonly type: 'release' } /** @@ -265,5 +260,5 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' } + | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: 'queue' | 'steer' } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index f3f6dd7451..8c9ca6c1c7 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -10,7 +10,7 @@ import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, - ReferenceInsert, SlashController, TokenSpan, + ReferenceInsert, SlashController, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-slash/client' import type { EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, @@ -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'): void + defaultSink(text: string, mode: 'queue' | 'steer', signal: AbortSignal): Promise } /** Guard tier from the machine phase. */ @@ -97,15 +97,6 @@ export class SessionInputShell implements SessionInput { this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) })) } - /** - * Clear the draft as a successful-send commit: no undo unit is recorded and - * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content - * (the command path gets the same discipline from submit-settled success). - */ - commitSend(): void { - this.run(this.core.dispatch({ type: 'send-committed' })) - } - /** * Insert a newline at the selection as one machine transaction (the * execCommand path is gone — a second undo history would fork). @@ -336,7 +327,7 @@ export class SessionInputShell implements SessionInput { return } case 'default-sink': { - this.sinkSerialized(fx.draft, fx.mode) + this.sinkSerialized(fx.attempt, fx.draft, fx.mode) return } default: @@ -351,10 +342,10 @@ export class SessionInputShell implements SessionInput { * send — notice + draft and chips retained, never a silent downgrade to * the clipboard text. Chip-free drafts skip the async detour. */ - private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void { + private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: 'queue' | 'steer'): void { const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.deps.defaultSink(draft.trim(), mode) + this.settleDefault(attempt, this.deps.defaultSink(draft.trim(), mode, attempt.signal)) return } const slash = this.deps.slash?.() @@ -374,13 +365,44 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + 1 } out += draft.slice(cursor) - this.deps.defaultSink(out.trim(), mode) + this.settleDefault(attempt, this.deps.defaultSink(out.trim(), mode, attempt.signal)) }, (error: unknown) => { controller.abort() if (this.disposed) return const message = error instanceof Error ? error.message : String(error) - this.notify('error', message) + this.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: false, + message, + })) + }, + ) + } + + private settleDefault( + attempt: SubmitAttempt, + pending: Promise, + ): void { + pending.then( + (outcome) => { + if (this.dead(attempt)) return + this.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: outcome.kind === 'success', + outcome, + })) + }, + (error: unknown) => { + if (this.dead(attempt)) return + this.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: false, + message: error instanceof Error ? error.message : String(error), + })) }, ) } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 2ae474be31..efb7ccb020 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -9,7 +9,7 @@ * real host entity, so the sink is one unconditional prompt path. */ import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { SlashController, SlashServiceContract, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import type {} from '@deepseek-ai/dsh-client-ui-slash/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' @@ -57,7 +57,7 @@ export class InputHub implements InputService { slash: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text, mode) => { this.sink(session, text, mode) }, + defaultSink: (text, mode, signal) => this.sink(session, text, mode, signal), }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -70,8 +70,13 @@ export class InputHub implements InputService { shell.insertReference(req.reference, req.span) ? true : undefined), actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined), - actx.on('slash/input-insert-text', req => - shell.insertText(req.text, req.span) ? true : undefined), + actx.on('slash/input-insert-text', (req) => { + if (!shell.insertText(req.text, req.span)) return undefined + if (req.continue === true) { + shell.track(shell.snapshot.draft, req.span.start + req.text.length) + } + return true + }), ] return () => { for (const off of offs) off() @@ -108,24 +113,21 @@ export class InputHub implements InputService { } /** - * Default sink: optimistic clear + prompt. The session is always a real - * host entity (materialized when its workspace was picked), so there is - * exactly one path; a failed first prompt is an ordinary prompt failure - * (error strip via promptError, draft restored only while untouched). + * Default sink: submit through the real host session and report acceptance + * to the input transaction. The draft and its reference occurrences remain + * resident until this promise succeeds. */ - private sink(session: Session, text: string, mode: 'queue' | 'steer'): void { - if (text === '') return - const shell = this.shells.get(session.sessionId) - // Commit, not an editable clear: undo must not resurrect sent content. - shell?.commitSend() - void session.prompt([{ type: 'text', text }], mode).then( - (result) => { - if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text) - }, - () => { - if (shell?.snapshot.draft === '') shell.setDraft(text) - }, - ) + private async sink( + session: Session, + text: string, + mode: 'queue' | 'steer', + signal: AbortSignal, + ): Promise { + if (text === '') return { kind: 'error', text: 'prompt is empty' } + const result = await session.prompt([{ type: 'text', text }], mode, signal) + return result.ok + ? { kind: 'success' } + : { kind: 'error', text: result.error.message } } private controller(actx: ClientContext): SlashController | undefined { diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index f9c5a479a4..21d5f3b019 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -167,7 +167,6 @@ export class InputMachine { case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'submit-settled': return this.onSubmitSettled(ev) - case 'send-committed': return this.onSendCommitted() case 'release': return this.onRelease() default: return unreachable(ev) } @@ -477,7 +476,9 @@ export class InputMachine { this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft: this.draft }] } - return [{ type: 'default-sink', draft: this.draft, mode }] + const attempt = this.beginAttempt(mode) + this.phase = 'submitting' + return [{ type: 'default-sink', attempt, draft: this.draft, mode }] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -495,11 +496,18 @@ export class InputMachine { } // 'handled' (source dealt internally), {insert} (no enter-time span // semantics), or a miss: all land plain; only the miss flows to the sink. + if (outcome === undefined) { + this.phase = 'submitting' + return [{ + type: 'default-sink', + attempt, + draft: attempt.draftSnapshot, + mode: flight.mode, + }] + } this.inflight = undefined this.phase = 'plain' - return outcome === undefined - ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }] - : [] + return [] } private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] { @@ -529,8 +537,8 @@ export class InputMachine { : [] } const text = ev.message ?? ev.outcome?.text ?? 'command failed' - // Drift guard: keep the enter-time draft (same claim) only while the - // live draft still equals it; user input typed during flight wins. + // Keep the same command claim only while the live draft still equals the + // enter-time draft; user input typed during flight wins. // Claimed re-entry additionally requires the watch to hold — an // enter-path snapshot may carry leading whitespace the token never had. if (this.draft === flight.attempt.draftSnapshot @@ -543,19 +551,6 @@ export class InputMachine { return [{ type: 'notice', level: 'error', text }] } - /** Ordinary send accepted: clear as a commit (no undo unit; sent content - * must not be resurrectable — same discipline as submit-settled success). */ - private onSendCommitted(): InputEffect[] { - this.claim = undefined - this.occurrences = [] - this.adopt('') - this.log = [] - this.redoStack = [] - this.typingRun = undefined - this.paste = undefined - return [] - } - private onRelease(): InputEffect[] { if (this.inflight !== undefined) { this.inflight.controller.abort() diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index da255415ce..37033179d3 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom // apply inject factories exercised end to end against the terminal thin // shape: the conversation surface (views triple, send choreography incl. -// optimistic clear + failure restore THROUGH the declared store actions, +// accepted-settlement clear + failure retention THROUGH the declared store actions, // openDetails = select action + layout orchestration, sessions.open // navigation), and the closeDetails details surface. Complements // chat-apply.spec.tsx (registration) @@ -185,7 +185,7 @@ describe('conversation slot inject surface', () => { expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1) }) - it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => { + it('the provide-channel input face submits through the machine sink: trim, accepted clear, failure retention without clobber', async () => { const b = await bench() const { injected } = b.conversationSurface(ROOT) const { state, actions } = b.inputSurface(ROOT) @@ -194,20 +194,27 @@ describe('conversation slot inject surface', () => { actions.submit('queue') expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(state.getSnapshot().draft).toBe(' ') - // Success: cleared and stays cleared. + // Success: retained while the host decides, then cleared on acceptance. actions.setDraft('hello') actions.submit('queue') - expect(state.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). + expect(state.getSnapshot().draft).toBe('hello') + await vi.waitFor(() => { + expect(state.getSnapshot().draft).toBe('') + }) + expect(b.sessionFake.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'hello' }], + 'queue', + expect.any(AbortSignal), + ) + // Failure: the original draft remains available for retry. b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) actions.setDraft('retry me') actions.submit('queue') + expect(state.getSnapshot().draft).toBe('retry me') await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('retry me') }) - // Failure landing after new typing: no clobber (restore fills empty only). + // Failure landing after new typing: no clobber. b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) actions.submit('queue') actions.setDraft('typed during flight') 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 be50356185..a48653a132 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -18,6 +18,52 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx afterEach(cleanup) describe('MessageItem arms', () => { + it('shows referenced-session labels below the direct user prompt', () => { + const view = render( + , + ) + expect(view.container.textContent).toContain('compare @Research notes') + expect(view.container.querySelector('[data-ref-chip="reference"]')?.textContent).toBe('@Research notes') + expect(view.getByText('引用会话 · Research notes, fallback')).toBeTruthy() + }) + + it('styles a referenced-session label when prompt text follows without whitespace', () => { + const view = render( + , + ) + expect(view.container.textContent).toContain('@你好这个在讲啥') + expect(view.container.querySelector('[data-ref-chip="reference"]')?.textContent).toBe('@你好') + }) + it('steering bubbles carry the interjection badge and non-text rest blocks', () => { const view = render( Promise.resolve({ kind: 'success' as const })) const lex = over?.lexicon type ShellDeps = ConstructorParameters[0] const shell = new SessionInputShell({ @@ -109,7 +109,7 @@ describe('Enter semantics', () => { it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('hello', 'queue') + expect(sink).toHaveBeenCalledWith('hello', 'queue', expect.any(AbortSignal)) fireEvent.keyDown(textarea, { key: 'Enter', repeat: true }) expect(sink).toHaveBeenCalledTimes(1) const empty = bench({ draft: ' ' }) @@ -177,7 +177,7 @@ describe('running and lock semantics (queue cut 1)', () => { expect(textarea.disabled).toBe(false) // running no longer locks fireEvent.change(textarea, { target: { value: '排队消息2' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2', 'queue') + expect(sink).toHaveBeenCalledWith('排队消息2', 'queue', expect.any(AbortSignal)) expect(button.getAttribute('aria-label')).toBe('Stop generating') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) @@ -193,7 +193,7 @@ describe('running and lock semantics (queue cut 1)', () => { it('idle primary sends and disables on empty draft', () => { const { button, sink } = bench({ draft: 'go' }) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('go', 'queue') + expect(sink).toHaveBeenCalledWith('go', 'queue', expect.any(AbortSignal)) const empty = bench() expect(empty.button.disabled).toBe(true) }) diff --git a/packages/client/ui-conversation/tests/input-machine.spec.ts b/packages/client/ui-conversation/tests/input-machine.spec.ts index 206a66e4c6..b6080d208d 100644 --- a/packages/client/ui-conversation/tests/input-machine.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.spec.ts @@ -72,9 +72,10 @@ describe('input-machine: plain × enter', () => { it('non-command text falls to the default sink with the given mode', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - expect(m.dispatch({ type: 'enter', mode: 'steer' })) - .toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }]) - expect(m.state.phase).toBe('plain') + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'steer' }), 0, 'default-sink') + expect(effect).toMatchObject({ draft: 'hello world', mode: 'steer' }) + expect(effect.attempt.draftSnapshot).toBe('hello world') + expect(m.state.phase).toBe('submitting') }) it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => { @@ -97,8 +98,8 @@ describe('input-machine: plain × enter', () => { it('a non-whitespace prefix before "/" is not leading — default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' }) - expect(m.dispatch({ type: 'enter', mode: 'queue' })) - .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }]) + expect(effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')) + .toMatchObject({ draft: '第一行\n/goal x', mode: 'queue' }) }) }) @@ -127,9 +128,12 @@ describe('input-machine: adjudication outcomes', () => { it('undefined outcome falls back to the default sink preserving the enter mode', () => { const m = new InputMachine() const attempt = enterAdjudicating(m, '/unknown thing', 'steer') - expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) - .toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }]) - expect(m.state.phase).toBe('plain') + expect(effectAt( + m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }), + 0, + 'default-sink', + )).toMatchObject({ attempt, draft: '/unknown thing', mode: 'steer' }) + expect(m.state.phase).toBe('submitting') }) it("'handled' lands plain with zero effects (popup shell path)", () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 6b60f7ea17..eefacee1f5 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -50,7 +50,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled } function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise }) { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink }) const wiring = shell const view = mountBar(shell, over) @@ -71,13 +71,16 @@ function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: s } describe('matrix row: plain', () => { - it('enter falls to the default sink; no claim on the currency; edits free', () => { + it('enter falls to the default sink; no claim on the currency; edits free', async () => { const { textarea, shell, sink } = bench() fireEvent.change(textarea, { target: { value: '普通消息' } }) expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('普通消息', 'queue') - expect(shell.snapshot.phase).toBe('plain') + expect(sink).toHaveBeenCalledWith('普通消息', 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.phase).toBe('submitting') + await vi.waitFor(() => { + expect(shell.snapshot.phase).toBe('plain') + }) }) }) @@ -175,7 +178,7 @@ describe('matrix row: locked (session disabled)', () => { expect((textarea as HTMLTextAreaElement).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队', 'queue') + expect(sink).toHaveBeenCalledWith('排队', 'queue', expect.any(AbortSignal)) }) }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.spec.ts new file mode 100644 index 0000000000..1fed5ef9ed --- /dev/null +++ b/packages/client/ui-conversation/tests/input-reference-submit.spec.ts @@ -0,0 +1,116 @@ +/** + * Reference-submit transaction coverage: chips serialize through their + * owner, stay resident through Host rejection, and clear only after an + * accepted prompt. + */ +import { describe, expect, it, vi } from 'vitest' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlashController, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' +import { SessionInputShell } from '../src/client/input/facade.ts' +import { PLACEHOLDER } from '../src/client/input/machine.ts' + +const mention = '@[Research](dsh-session:InNvdXJjZSI)' + +function chip(shell: SessionInputShell): void { + shell.setDraft('@res') + const accepted = shell.insertReference({ + source: 'reference', + ref: mention, + label: '@Research', + clipboardText: mention, + }, { + start: 0, + end: 4, + draftRev: shell.snapshot.draftRev, + }) + expect(accepted).toBe(true) +} + +describe('reference submission', () => { + it('retains the chip on Host failure and clears it only after a later accepted retry', async () => { + const serializeReference = vi.fn(() => Promise.resolve(mention)) + const sink = vi.fn<(_text: string, _mode: 'queue' | 'steer') => Promise>() + .mockResolvedValueOnce({ kind: 'error', text: 'snapshot unavailable' }) + .mockResolvedValueOnce({ kind: 'success' }) + const slash = { + serializeReference, + track: vi.fn(), + } as unknown as SlashController + const shell = new SessionInputShell({ + actx: {} as ClientContext, + slash: () => slash, + defaultSink: sink, + }) + chip(shell) + expect(shell.snapshot).toMatchObject({ + draft: PLACEHOLDER, + occurrences: [{ source: 'reference', ref: mention, label: '@Research' }], + }) + + shell.submit('queue') + expect(shell.snapshot.phase).toBe('submitting') + await vi.waitFor(() => { + expect(shell.snapshot.phase).toBe('plain') + }) + expect(sink).toHaveBeenNthCalledWith(1, mention, 'queue', expect.any(AbortSignal)) + expect(shell.snapshot).toMatchObject({ + draft: PLACEHOLDER, + occurrences: [{ source: 'reference', ref: mention, label: '@Research' }], + }) + expect(shell.notices.getSnapshot()).toMatchObject({ + level: 'error', + text: 'snapshot unavailable', + }) + + shell.submit('queue') + await vi.waitFor(() => { + expect(shell.snapshot.draft).toBe('') + }) + expect(sink).toHaveBeenNthCalledWith(2, mention, 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.occurrences).toEqual([]) + expect(serializeReference).toHaveBeenCalledTimes(2) + }) + + it('blocks submission and retains the chip when its owner cannot serialize it', async () => { + const sink = vi.fn() + const slash = { + serializeReference: () => Promise.reject(new Error('reference codec unavailable')), + track: vi.fn(), + } as unknown as SlashController + const shell = new SessionInputShell({ + actx: {} as ClientContext, + slash: () => slash, + defaultSink: sink, + }) + chip(shell) + shell.submit() + await vi.waitFor(() => { + expect(shell.snapshot.phase).toBe('plain') + }) + expect(sink).not.toHaveBeenCalled() + expect(shell.snapshot.draft).toBe(PLACEHOLDER) + expect(shell.snapshot.occurrences).toHaveLength(1) + expect(shell.notices.getSnapshot()).toMatchObject({ + level: 'error', + text: 'reference codec unavailable', + }) + }) + + it('aborts Host-side preparation when the input shell is disposed', () => { + let signal: AbortSignal | undefined + const shell = new SessionInputShell({ + actx: {} as ClientContext, + defaultSink: (_text, _mode, received) => { + signal = received + return new Promise(() => {}) + }, + }) + shell.setDraft('send this') + shell.submit() + expect(signal?.aborted).toBe(false) + shell.dispose() + expect(signal?.aborted).toBe(true) + expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('send this') + }) +}) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 9a99eccbf0..4ba6926094 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -102,7 +102,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { register?.(slash) const actx = sessions.scope(sessionId)! as ClientContext const controller = slash.sessionOf(actx) - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink }) // The hub's listener wiring, verbatim. actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined) @@ -215,7 +215,9 @@ describe('scenario D: execute-kind /compact', () => { act(() => { b2.shell.setDraft('/compact 现在') }) fireEvent.keyDown(b2.textarea, { key: 'Enter' }) // execute with trailing → matchEnter answers undefined → default sink. - await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') }) + await vi.waitFor(() => { + expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue', expect.any(AbortSignal)) + }) expect(b2.executed).toHaveLength(0) }) }) @@ -240,7 +242,9 @@ describe('scenario I: unknown /xyz + enter', () => { const b = await bench() act(() => { b.shell.setDraft('/xyz 干点啥') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') }) + await vi.waitFor(() => { + expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue', expect.any(AbortSignal)) + }) expect(b.shell.snapshot.phase).toBe('plain') expect(b.execute).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 623ee93202..64792fa8e0 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -21,7 +21,7 @@ import type { ComposerBarOwnerProps } from '../src/client/contract/slots.ts' /** Machine-backed wiring over a sink spy. */ function fakeWiring() { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink }) return { wiring: shell, sink, shell } } @@ -152,7 +152,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue') + expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue', expect.any(AbortSignal)) fireEvent.click(b.view.getByRole('button', { name: 'Root' })) expect(b.open).toHaveBeenCalledWith(sid('root')) }) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml similarity index 55% rename from packages/client/ui-subagent/README.i18n.yaml rename to packages/client/ui-reference/README.i18n.yaml index 86995fc65c..9cfc4baf07 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-reference/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 7a70add139eae7bc507469b4fe7170359efdec31 -README.zh.md: 2d8ee677c71179df88211d90120a6017ceac8f6a +# pnpm run verify-translation-pairing --write packages/client/ui-reference/README.md +README.md: e7b280c09cf33f9c0c38ebffe0c5e4a9d22062fc +README.zh.md: 4d9d41d24a9a4a61859a7fcf8d19cdb8742c475e diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md new file mode 100644 index 0000000000..e7b280c09c --- /dev/null +++ b/packages/client/ui-reference/README.md @@ -0,0 +1,25 @@ +# `@deepseek-ai/dsh-client-ui-reference` + +English | [中文](README.zh.md) + +Unified Web `@file` and `@session` source. The browser starts `reference.files` and `reference.sessions` Host RPCs together for an unquoted token, keeps the TUI's file-before-session ordering and labels, renders the rows under the non-selectable `文件与文件夹` and `Session 对话` headings, and degrades either failed candidate domain independently. An open `@"…` token searches files only. + +File picks insert the natural `@path` text used by the TUI. A file closes completion and adds a trailing space; a directory keeps the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted. + +Session picks insert an atomic composer chip whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. The visible chip uses `@label`; serialization never reconstructs identity from that label. Ordinary send delegates the canonical mention to `session.prompt`, where Host-side session-reference preparation owns validation, snapshotting, and model context. + +The `/client` export is the plugin body (`apply`/`inject`) only; candidate encoding stays internal to the registration effect. + +## Model Experience + +Indirectly, through `@deepseek-ai/dsh-file-reference-local` for path guidance and `@deepseek-ai/dsh-session-reference` for prepared session snapshots. + +#### KV Cache effect + +Candidate browsing has no model effect. A selected file or session changes only the new user-message suffix and any Host-prepared session-reference prefix attached to that message; earlier target history remains unchanged. + +## Known Limitations and Deferred Work + +- **Candidate failure is intentionally quiet** — one unavailable or failed reference RPC yields no rows for that domain, while prompt submission still reports session-reference preparation failures through the ordinary send path. +- **No browser-side file scan** — Web completion requires a mounted Host `ctx.fileReferences` provider; the browser cannot fall back to its own filesystem. +- **Session search remains metadata-only** — discovery filters session id and cwd through `ctx.sessionReferences`; title and transcript full-text search are not available. diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md new file mode 100644 index 0000000000..4d9d41d24a --- /dev/null +++ b/packages/client/ui-reference/README.zh.md @@ -0,0 +1,25 @@ +# `@deepseek-ai/dsh-client-ui-reference` + +[English](README.md) | 中文 + +统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `reference.files` 和 `reference.sessions` 宿主 RPC,沿用 TUI 中文件在会话之前的顺序和标签,把各行分别渲染在不可选择的 `文件与文件夹` 和 `Session 对话` 标题下,并让任一候选领域的失败独立降级。尚未闭合的 `@"…` token 只搜索文件。 + +选择文件会插入 TUI 使用的自然 `@path` 文本。文件会关闭补全并追加一个尾随空格;目录则让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。 + +选择会话会插入一个原子的输入框 chip,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` 提及标记。可见 chip 使用 `@label`;序列化永远不会根据该标签重建身份。普通发送会把规范提及标记交给 `session.prompt`,由宿主侧的会话引用准备负责校验、生成快照和模型上下文。 + +`/client` 只导出插件主体(`apply`/`inject`);候选编码保留在注册 effect 内部。 + +## 模型体验 + +间接影响模型体验:路径指引由 `@deepseek-ai/dsh-file-reference-local` 提供,准备后的会话快照由 `@deepseek-ai/dsh-session-reference` 提供。 + +#### KV 缓存影响 + +浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及附加到该消息、由宿主准备的会话引用前缀;目标会话更早的历史保持不变。 + +## 已知限制与暂缓事项 + +- **候选失败有意保持静默**:引用 RPC 不可用或失败时,该领域不产生候选行;提示词提交仍会通过普通发送路径报告会话引用准备失败。 +- **浏览器侧不扫描文件**:Web 补全需要挂载宿主 `ctx.fileReferences` 提供方;浏览器无法回退到自身文件系统。 +- **会话搜索仍仅使用元数据**:发现流程通过 `ctx.sessionReferences` 筛选 session id 和 cwd;无法对标题和 transcript(文本记录)进行全文搜索。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-reference/package.json similarity index 78% rename from packages/client/ui-subagent/package.json rename to packages/client/ui-reference/package.json index 9ff379b676..03825e0cd3 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-reference/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-ui-subagent", - "description": "Subagent reference source: '@' menu candidates from the session snapshot (zero RPC), inserts @label references", + "name": "@deepseek-ai/dsh-client-ui-reference", + "description": "Unified Web @file and @session reference source", "version": "0.0.1", "private": true, "type": "module", @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-slash" ], @@ -35,17 +36,19 @@ }, "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-slash": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-file-reference": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", "cordis": "^4.0.0-rc.7" }, "files": [ diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts new file mode 100644 index 0000000000..157498f820 --- /dev/null +++ b/packages/client/ui-reference/src/client/index.ts @@ -0,0 +1,115 @@ +/** + * Unified Web `@` reference source. File and session discovery run through + * cancellable Host RPCs in parallel and retain the TUI's ordering and labels. + * + * @module @deepseek-ai/dsh-client-ui-reference/client + */ +import type { ConnectionHandle, FileReferenceItem, SessionReferenceItem } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' + +const FILE_SECTION = '文件与文件夹' +const SESSION_SECTION = 'Session 对话' + +/** Required services: the slash registry and Host connection. */ +export const inject = ['slash', 'connection'] + +/** + * Register the combined `@file` / `@session` source. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + const references = (ctx.get('connection') as ConnectionHandle).api.references + const source: SlashSource = { + trigger: '@', + name: 'reference', + async candidates(session: ClientSessionContext, { query, quoted, signal }) { + const files = references.files({ sessionId: session.sessionId, query }, signal).then( + response => response.result.ok ? response.result.value.items : [], + () => [], + ) + const sessions = quoted === true + ? Promise.resolve([] as SessionReferenceItem[]) + : references.sessions({ sessionId: session.sessionId, query }, signal).then( + response => response.result.ok ? response.result.value.items : [], + () => [], + ) + const [fileItems, sessionItems] = await Promise.all([files, sessions]) + if (signal.aborted) return [] + return [ + ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true)), + ...sessionItems.map(sessionCandidate), + ] + }, + onPick({ candidate }) { + const value = parseCandidate(candidate.value) + if (value?.kind === 'file') { + return { + text: value.mention + (value.fileKind === 'file' ? ' ' : ''), + ...value.fileKind === 'directory' ? { continue: true } : {}, + } + } + if (value?.kind === 'session') { + return { + insert: { + source: 'reference', + ref: value.mention, + label: `@${value.label}`, + clipboardText: value.mention, + }, + } + } + return undefined + }, + codec: { + clipboardText: ref => ref, + serialize: ref => Promise.resolve(ref), + }, + } + const slash = ctx.get('slash') as SlashServiceContract + ctx.effect(() => slash.registerSource(source), 'ui-reference: @ source') +} + +type ReferenceCandidateValue = + | { kind: 'file'; fileKind: FileReferenceItem['kind']; mention: string } + | { kind: 'session'; label: string; mention: string } + +function fileCandidate(candidate: FileReferenceItem, preserveQuote: boolean) { + const mention = formatFileMention(candidate, preserveQuote) + if (mention === undefined) return [] + const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) + const directory = candidate.kind === 'directory' + const value: ReferenceCandidateValue = { + kind: 'file', + fileKind: candidate.kind, + mention, + } + return [{ + name: `${directory ? 'Folder' : 'File'} · ${name}${directory ? '/' : ''}`, + description: candidate.path, + section: FILE_SECTION, + value: JSON.stringify(value), + }] +} + +function sessionCandidate(candidate: SessionReferenceItem) { + const location = candidate.cwd ?? '(no cwd)' + const description = `${candidate.label === candidate.sessionId ? '' : `${candidate.sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` + const value: ReferenceCandidateValue = { + kind: 'session', + label: candidate.label, + mention: candidate.mention, + } + return { + name: `Session · ${candidate.label}`, + description, + section: SESSION_SECTION, + value: JSON.stringify(value), + } +} + +function parseCandidate(value: string | undefined): ReferenceCandidateValue | undefined { + if (value === undefined) return undefined + return JSON.parse(value) as ReferenceCandidateValue +} diff --git a/packages/client/ui-subagent/src/index.ts b/packages/client/ui-reference/src/index.ts similarity index 79% rename from packages/client/ui-subagent/src/index.ts rename to packages/client/ui-reference/src/index.ts index 825b860701..b8c3f990f8 100644 --- a/packages/client/ui-subagent/src/index.ts +++ b/packages/client/ui-reference/src/index.ts @@ -1,5 +1,5 @@ /** - * Subagent reference plugin, node half. Pure UI plugin: the empty apply + * File/session 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. diff --git a/packages/client/ui-subagent/src/invariant.ts b/packages/client/ui-reference/src/invariant.ts similarity index 83% rename from packages/client/ui-subagent/src/invariant.ts rename to packages/client/ui-reference/src/invariant.ts index 645f88c9b6..8f4c694cea 100644 --- a/packages/client/ui-subagent/src/invariant.ts +++ b/packages/client/ui-reference/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-subagent`. - * @module @deepseek-ai/dsh-client-ui-subagent/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-reference`. + * @module @deepseek-ai/dsh-client-ui-reference/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-subagent' +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-reference' /** Cordis companion plugin name. */ -export const name = 'client-ui-subagent-invariant' +export const name = 'client-ui-reference-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/client/ui-reference/tests/browser-plugin.spec.ts b/packages/client/ui-reference/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..ca70d56dcc --- /dev/null +++ b/packages/client/ui-reference/tests/browser-plugin.spec.ts @@ -0,0 +1,325 @@ +/** + * Web reference source coverage: Host-backed file/session discovery, TUI + * ordering and labels, quoted-path suppression, pick projections, codec + * round-trip, and registration lifecycle. + */ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import type { FileReferenceItem, SessionReferenceItem } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + CandidateRequest, ClientSessionContext, SlashCandidate, SlashSource, +} from '@deepseek-ai/dsh-client-ui-slash/client' +import { apply, inject } from '../src/client/index.ts' + +const sid = (value: string): SessionId => value as SessionId +const session: ClientSessionContext = { sessionId: sid('target') } + +type ReferenceResponse = + | { result: { ok: true; value: { items: T[] } } } + | { result: { ok: false; error: { code: string; message: string } } } + +type ReferenceLookup = ( + payload: unknown, + signal?: AbortSignal, +) => Promise> + +function request( + query: string, + options: { quoted?: boolean; signal?: AbortSignal } = {}, +): CandidateRequest { + return { + query, + position: 'inline', + signal: options.signal ?? new AbortController().signal, + ...options.quoted === undefined ? {} : { quoted: options.quoted }, + } +} + +async function bench( + files: ReferenceLookup = vi.fn(() => Promise.resolve({ + result: { + ok: true as const, + value: { + items: [ + { path: 'src', kind: 'directory' as const }, + { path: 'docs/a b.md', kind: 'file' as const }, + ], + }, + }, + })), + sessions: ReferenceLookup = vi.fn(() => Promise.resolve({ + result: { + ok: true as const, + value: { + items: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 1_700_000_000_000, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], + }, + }, + })), +): Promise<{ ctx: Context; fiber: ReturnType; source: SlashSource }> { + const ctx = new Context() + let source: SlashSource | undefined + ctx.provide('slash', { + registerSource(candidate: SlashSource) { + source = candidate + return () => { source = undefined } + }, + }) + ctx.provide('connection', { api: { references: { files, sessions } } } as never) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + if (source === undefined) throw new Error('reference source was not registered') + return { ctx, fiber, source } +} + +describe('apply', () => { + it('declares its services and releases the @ reference registration on disposal', async () => { + expect(inject).toEqual(['slash', 'connection']) + const ctx = new Context() + let registered: SlashSource | undefined + ctx.provide('slash', { + registerSource(source: SlashSource) { + registered = source + return () => { registered = undefined } + }, + }) + ctx.provide('connection', { + api: { + references: { + files: () => Promise.resolve({ result: { ok: true, value: { items: [] } } }), + sessions: () => Promise.resolve({ result: { ok: true, value: { items: [] } } }), + }, + }, + } as never) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(registered).toMatchObject({ trigger: '@', name: 'reference' }) + await fiber.dispose() + expect(registered).toBeUndefined() + }) +}) + +describe('candidates', () => { + it('starts both Host lookups together and renders files before sessions with TUI labels', async () => { + let releaseFiles!: () => void + let releaseSessions!: () => void + const files = vi.fn(() => new Promise<{ + result: { ok: true; value: { items: { path: string; kind: 'file' | 'directory' }[] } } + }>((resolve) => { + releaseFiles = () => { + resolve({ + result: { + ok: true, + value: { + items: [ + { path: 'src', kind: 'directory' }, + { path: 'docs/a b.md', kind: 'file' }, + ], + }, + }, + }) + } + })) + const sessions = vi.fn(() => new Promise<{ + result: { + ok: true + value: { + items: { + sessionId: SessionId + label: string + cwd: string + createdAt: number + mention: string + }[] + } + } + }>((resolve) => { + releaseSessions = () => { + resolve({ + result: { + ok: true, + value: { + items: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 1_700_000_000_000, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], + }, + }, + }) + } + })) + const { source } = await bench(files, sessions) + const pending = source.candidates(session, request('re')) + expect(files).toHaveBeenCalledTimes(1) + expect(sessions).toHaveBeenCalledTimes(1) + releaseSessions() + releaseFiles() + await expect(pending).resolves.toEqual([ + expect.objectContaining({ + name: 'Folder · src/', + description: 'src', + section: '文件与文件夹', + }), + expect.objectContaining({ + name: 'File · a b.md', + description: 'docs/a b.md', + section: '文件与文件夹', + }), + expect.objectContaining({ + name: 'Session · Research', + description: 'source · /project · 2023-11-14T22:13:20.000Z', + section: 'Session 对话', + }), + ]) + }) + + it('suppresses sessions for an open quoted path and degrades each failed domain independently', async () => { + const files = vi.fn() + .mockResolvedValueOnce({ + result: { + ok: true as const, + value: { items: [{ path: 'README.md', kind: 'file' as const }] }, + }, + }) + .mockRejectedValueOnce(new Error('file scan failed')) + const sessions = vi.fn(() => Promise.resolve({ + result: { + ok: true as const, + value: { + items: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 0, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], + }, + }, + })) + const { source } = await bench(files, sessions) + const quoted = await source.candidates(session, request('READ', { quoted: true })) + expect(quoted).toEqual([expect.objectContaining({ name: 'File · README.md' })]) + expect(source.onPick({ + candidate: quoted[0]!, + session, + position: 'inline', + via: 'menu', + span: { start: 0, end: 6, draftRev: 1 }, + })).toEqual({ text: '@"README.md" ' }) + expect(sessions).not.toHaveBeenCalled() + await expect(source.candidates(session, request('research'))).resolves.toEqual([ + expect.objectContaining({ name: 'Session · Research' }), + ]) + }) + + it('drops a completed result when the query signal was superseded', async () => { + const controller = new AbortController() + const { source } = await bench() + const pending = source.candidates(session, request('', { signal: controller.signal })) + controller.abort() + await expect(pending).resolves.toEqual([]) + }) + + it('treats Host errors as empty domains and filters paths that cannot be mentioned', async () => { + const files = vi.fn(() => Promise.resolve({ + result: { + ok: true as const, + value: { items: [{ path: 'bad\nname', kind: 'file' as const }] }, + }, + })) + const sessions = vi.fn() + .mockRejectedValueOnce(new Error('session lookup failed')) + .mockResolvedValueOnce({ + result: { + ok: false as const, + error: { code: 'reference-failed', message: 'session lookup failed' }, + }, + }) + const { source } = await bench(files, sessions) + await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) + + files.mockResolvedValueOnce({ + result: { + ok: false as const, + error: { code: 'reference-failed', message: 'file lookup failed' }, + }, + } as never) + await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) + }) + + it('omits redundant session ids and labels sessions without a cwd', async () => { + const files = vi.fn(() => Promise.resolve({ + result: { ok: true as const, value: { items: [] } }, + })) + const sessions = vi.fn(() => Promise.resolve({ + result: { + ok: true as const, + value: { + items: [{ + sessionId: sid('same'), + label: 'same', + createdAt: 0, + mention: '@[same](dsh-session:InNhbWUi)', + }], + }, + }, + })) + const { source } = await bench(files, sessions) + await expect(source.candidates(session, request('same'))).resolves.toEqual([ + expect.objectContaining({ + name: 'Session · same', + description: '(no cwd) · 1970-01-01T00:00:00.000Z', + }), + ]) + }) +}) + +describe('pick and codec', () => { + const pick = (source: SlashSource, candidate: SlashCandidate) => source.onPick({ + candidate, + session, + position: 'inline', + via: 'menu', + span: { start: 0, end: 1, draftRev: 1 }, + }) + + it('inserts files as path text, keeping directory completion open', async () => { + const { source } = await bench() + const [directory, file] = await source.candidates(session, request('')) + expect(pick(source, directory!)).toEqual({ text: '@src/', continue: true }) + expect(pick(source, file!)).toEqual({ text: '@"docs/a b.md" ' }) + const [quotedDirectory] = await source.candidates(session, request('', { quoted: true })) + expect(pick(source, quotedDirectory!)).toEqual({ text: '@"src/', continue: true }) + }) + + it('inserts sessions as atomic chips whose clipboard and model forms are canonical mentions', async () => { + const { source } = await bench() + const candidates = await source.candidates(session, request('')) + const candidate = candidates.find(item => item.name === 'Session · Research')! + const mention = '@[Research](dsh-session:InNvdXJjZSI)' + expect(pick(source, candidate)).toEqual({ + insert: { + source: 'reference', + ref: mention, + label: '@Research', + clipboardText: mention, + }, + }) + expect(source.codec?.clipboardText(mention)).toBe(mention) + await expect(source.codec?.serialize(mention, new AbortController().signal)).resolves.toBe(mention) + }) + + it('ignores candidates that do not carry a source-owned value', async () => { + const { source } = await bench() + expect(pick(source, { name: 'foreign candidate' })).toBeUndefined() + }) +}) diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-reference/tsconfig.json similarity index 81% rename from packages/client/ui-subagent/tsconfig.json rename to packages/client/ui-reference/tsconfig.json index b33f801293..3662140f2a 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../connection" + }, { "path": "../runtime" }, @@ -18,7 +21,7 @@ "path": "../ui-slash" }, { - "path": "../ui-slots" + "path": "../../context/file-reference" }, { "path": "../../support/invariants" diff --git a/packages/client/ui-reference/tsdown.config.ts b/packages/client/ui-reference/tsdown.config.ts new file mode 100644 index 0000000000..1c70dc948a --- /dev/null +++ b/packages/client/ui-reference/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-reference', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml index c09d7f4c28..331181646d 100644 --- a/packages/client/ui-slash/README.i18n.yaml +++ b/packages/client/ui-slash/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a -README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018 +# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md +README.md: 45bdff01605b51f18e08732930e8b35fec81f872 +README.zh.md: 6af98eb81edc66f5bf446f1b78e4b3dc6f0d1ce8 diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md index d2978695d7..45bdff0160 100644 --- a/packages/client/ui-slash/README.md +++ b/packages/client/ui-slash/README.md @@ -2,11 +2,13 @@ English | [中文](README.zh.md) -Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. +Input trigger pipeline plugin: `/` and `@` detection under the caret, the grouped candidate menu, and pick routing to registered sources. Slash detection keeps its word-boundary and guard-tier rules; `@` uses the shared TUI grammar and opens only at input start or after whitespace, including an unfinished `@"path with spaces` token. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration. -MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. +MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. A candidate's optional `section` renders one non-selectable heading for each contiguous section without entering the keyboard-selection index. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. + +Pick outcomes may insert plain text, an atomic reference chip, or request continued completion. Continued text picks replace the active token, then immediately retrack the resulting draft; `@file` directories use this path to keep completion open below the selected directory. The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it. @@ -23,4 +25,4 @@ None; this package neither assembles nor sends a provider request. - **Global source layer only** — session-scope source registration (per-session shadowing, ScopedLayers-alike) is designed but not enabled; the ledger tracks the trigger condition (a real per-session source need). - **`SlashCandidate.icon` renders as text** — MenuView drops the string into the icon slot verbatim; wiring to the design-system icon enum (iconFile five-variant family) lands when that enum ships. - **Overlay SlotMap merge home is split from slot ownership** — the `conversation.input.overlay` merge lives here (sole copy) while the slot's owner semantics (anchor, children declaration, lifecycle) stay with ui-conversation; the dependency direction (ui-conversation → ui-slash) forces the split, so a future dependency reshuffle should revisit it. -- **Menu group order is registration order** — no explicit ordering seam across sources; acceptable while the roster is command/skill/subagent, revisit if business sources join. +- **Menu group order is registration order** — no explicit ordering seam across sources; acceptable while the roster is command/skill/reference, revisit if more business sources join. diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md index 6aeb078a92..6af98eb81e 100644 --- a/packages/client/ui-slash/README.zh.md +++ b/packages/client/ui-slash/README.zh.md @@ -2,11 +2,13 @@ [English](README.md) | 中文 -输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 +输入触发流水线插件:光标处的 `/` 与 `@` 检测、分组候选菜单,以及把 pick 路由到已注册 source。斜杠命令检测沿用其词边界与 guard tier 规则;`@` 使用 TUI 的共享语法,只会在输入开头或空白后打开,也能识别尚未闭合的 `@"path with spaces` token。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。流水线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。 -MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。该 slot 由 ui-conversation 的编辑器配置项拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 +MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。候选项的可选 `section` 字段会为每段连续分组渲染一个不可选择的标题,且不会进入键盘选择索引。该 slot 由 ui-conversation 的编辑器配置项拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 + +pick 结果可以插入普通文本、原子引用 chip,或者请求继续补全。需要继续补全的文本 pick 会替换活跃 token,随后立即根据新草稿重新跟踪;`@file` 目录通过此路径让补全在所选目录下保持打开。 `/client` 导出表层是插件主体(`apply`/`inject`)、`SlashService`、`MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。 @@ -23,4 +25,4 @@ MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类 - **只有全局 source 层**:会话 scope 的 source 注册(逐会话遮蔽、类 ScopedLayers 机制)已有设计但未启用;台账记录着触发条件(出现真实的逐会话 source 需求)。 - **`SlashCandidate.icon` 以文本渲染**:MenuView 把该字符串原样放进图标位;接到设计系统图标枚举(iconFile 五变体家族)的接线等该枚举交付后落地。 - **overlay 的 SlotMap 合并归属与 slot 所有权分离**:`conversation.input.overlay` 的合并放在本包(唯一副本),而该 slot 的 owner 语义(锚点、children 声明、生命周期)留在 ui-conversation;依赖方向(ui-conversation → ui-slash)迫使这一拆分,未来依赖关系调整时应重新审视。 -- **菜单组顺序即注册顺序**:source 之间没有显式排序 seam;roster 还是 command/skill/subagent 时可以接受,业务 source 加入后需重新审视。 +- **菜单组顺序即注册顺序**:source 之间没有显式排序 seam;roster 还是 command/skill/reference 时可以接受,更多业务 source 加入后需重新审视。 diff --git a/packages/client/ui-slash/package.json b/packages/client/ui-slash/package.json index 1c376c5492..5d60dae847 100644 --- a/packages/client/ui-slash/package.json +++ b/packages/client/ui-slash/package.json @@ -37,6 +37,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-file-reference": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -44,6 +45,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-slash/src/client/MenuView.module.css b/packages/client/ui-slash/src/client/MenuView.module.css index bb41e949d0..1f9128c199 100644 --- a/packages/client/ui-slash/src/client/MenuView.module.css +++ b/packages/client/ui-slash/src/client/MenuView.module.css @@ -44,6 +44,20 @@ background: var(--dsw-alias-interactive-bg-hover); } +.sectionTitle { + flex: none; + min-height: 26px; + padding: 6px 10px 2px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + font-weight: 500; + line-height: 18px; +} + +.sectionTitle:not(:first-child) { + margin-top: 4px; +} + .itemIcon { display: inline-flex; flex: none; diff --git a/packages/client/ui-slash/src/client/MenuView.tsx b/packages/client/ui-slash/src/client/MenuView.tsx index a6336e71e1..e564b0bdc0 100644 --- a/packages/client/ui-slash/src/client/MenuView.tsx +++ b/packages/client/ui-slash/src/client/MenuView.tsx @@ -6,7 +6,7 @@ * pattern — focus never leaves the textarea, so rows are mousedown-handled * and the highlight is exposed via aria-activedescendant on the listbox). */ -import { useSyncExternalStore } from 'react' +import { Fragment, useSyncExternalStore } from 'react' import clsx from 'clsx' import css from './MenuView.module.css' import type { MenuViewInjected } from './slots.ts' @@ -40,25 +40,29 @@ export function MenuView({ menu, onPick }: MenuViewInjected) { : group.items.map((item, index) => { const active = highlight !== null && highlight.source === group.source && highlight.index === index return ( - + + {item.section !== undefined && item.section !== group.items[index - 1]?.section + ?
{item.section}
+ : null} + +
) }))}
diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index d3d3567e4d..e54255937f 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -75,6 +75,7 @@ export class SlashController { const prev = this.menu.getSnapshot() const same = prev.open && prev.hit !== null && prev.hit.trigger === hit.trigger && prev.hit.query === hit.query + && prev.hit.quoted === hit.quoted && prev.hit.span.start === hit.span.start && prev.hit.span.end === hit.span.end this.hit = hit if (same) return @@ -243,7 +244,11 @@ export class SlashController { return actx.bail(actx, 'slash/input-begin-command', { claim: outcome.claim, span }) === true } if ('text' in outcome) { - return actx.bail(actx, 'slash/input-insert-text', { text: outcome.text, span }) === true + return actx.bail(actx, 'slash/input-insert-text', { + text: outcome.text, + span, + ...outcome.continue === true ? { continue: true } : {}, + }) === true } return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true } @@ -278,7 +283,12 @@ export class SlashController { const projection = this.project() for (const source of roster) { void source - .candidates(projection, { query: hit.query, position: hit.position, signal: controller.signal }) + .candidates(projection, { + query: hit.query, + quoted: hit.quoted, + position: hit.position, + signal: controller.signal, + }) .then( (items) => { if (controller.signal.aborted) return diff --git a/packages/client/ui-slash/src/core/contract.ts b/packages/client/ui-slash/src/core/contract.ts index 852ac2bf10..d877416060 100644 --- a/packages/client/ui-slash/src/core/contract.ts +++ b/packages/client/ui-slash/src/core/contract.ts @@ -11,6 +11,8 @@ export interface TriggerHit { readonly trigger: TriggerChar /** Text between the trigger char and the caret, live-filtered. */ readonly query: string + /** True only for an open quoted `@file` token. */ + readonly quoted: boolean /** leading = draft trimmed (whitespace incl. newlines) starts with the token. */ readonly position: TriggerPosition /** Token span; draftRev injected by the caller. */ @@ -19,8 +21,9 @@ export interface TriggerHit { /** * Detect a trigger token at the caret under the given guard tier. - * Word-boundary rule: the char before the trigger is start-of-line, - * whitespace, or punctuation; `user@host` and URL '/' do not trigger. + * `@` uses the shared file-reference start/whitespace grammar; `/` accepts + * punctuation boundaries with URL carve-outs. `user@host` and URL `/` do not + * trigger. * Returns null when no trigger is live at the caret. */ export type DetectTrigger = (draft: string, caret: number, guard: TriggerGuard) => TriggerHit | null diff --git a/packages/client/ui-slash/src/core/detect.ts b/packages/client/ui-slash/src/core/detect.ts index 5f2e43680c..4fa30788fa 100644 --- a/packages/client/ui-slash/src/core/detect.ts +++ b/packages/client/ui-slash/src/core/detect.ts @@ -3,6 +3,7 @@ * the caret for a live trigger char under the guard tier and applies the * word-boundary rules. Zero React / DOM / cordis. */ +import { activeAtToken } from '@deepseek-ai/dsh-file-reference/grammar' import type { TriggerChar } from '../types.ts' import type { DetectTrigger } from './contract.ts' @@ -29,10 +30,10 @@ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean { } /** - * Detect a trigger token at the caret. Scans left from the caret and stops - * at the first whitespace (the token under edit never spans whitespace); - * trigger chars failing the guard tier or the word boundary are treated as - * ordinary token chars and the scan continues (`user@host`, URL slashes). + * Detect a trigger token at the caret. `@` first uses the shared grammar, + * including an open quoted token that may span whitespace. Slash detection + * scans left to the first whitespace; slashes failing the word boundary are + * treated as ordinary token chars and the scan continues (URL slashes). * Guard tiers: plain = both chars live; claimed = '/' fully suppressed, * '@' live; frozen = none. * @@ -46,15 +47,27 @@ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean { */ export const detectTrigger: DetectTrigger = (draft, caret, guard) => { if (guard.tier === 'frozen') return null + const at = activeAtToken(draft, caret) + if (at !== undefined) { + const start = caret - at.prefix.length + return { + trigger: '@', + query: at.query, + quoted: at.quoted, + position: draft.search(/\S/) === start ? 'leading' : 'inline', + span: { start, end: caret, draftRev: 0 }, + } + } for (let i = caret - 1; i >= 0; i--) { const ch = draft.charAt(i) if (WHITESPACE.test(ch)) return null - if (ch !== '/' && ch !== '@') continue - if (guard.tier === 'claimed' && ch === '/') continue + if (ch !== '/') continue + if (guard.tier === 'claimed') continue if (!boundaryOk(draft, i, ch)) continue return { trigger: ch, query: draft.slice(i + 1, caret), + quoted: false, position: draft.search(/\S/) === i ? 'leading' : 'inline', span: { start: i, end: caret, draftRev: 0 }, } diff --git a/packages/client/ui-slash/src/types.ts b/packages/client/ui-slash/src/types.ts index 2fb26fa4b6..9209daae3d 100644 --- a/packages/client/ui-slash/src/types.ts +++ b/packages/client/ui-slash/src/types.ts @@ -1,6 +1,6 @@ /** * Frozen cross-package contract for the input trigger pipeline. Types only — - * no runtime code. Sources (ui-command / ui-skill / ui-subagent) and the + * no runtime code. Sources (ui-command / ui-skill / ui-reference) and the * conversation input layer import from here; changes require main-thread * arbitration. * @@ -33,6 +33,10 @@ export type PickVia = 'menu' | 'space' | 'enter' /** One menu candidate. Pure display data — zero behavior declaration. */ export interface SlashCandidate { readonly name: string + /** Source-owned stable value when the display name is not the identity. */ + readonly value?: string + /** Presentation-only heading rendered once for each contiguous section. */ + readonly section?: string readonly description?: string readonly icon?: string readonly hint?: string @@ -89,13 +93,15 @@ export interface SubmitOutcome { export type PickOutcome = | { readonly claim: CommandClaim } | { readonly insert: ReferenceInsert } - | { readonly text: string } + | { readonly text: string; readonly continue?: boolean } | 'handled' | undefined /** Candidate request passed to a source. The signal is superseded on query change / menu close. */ export interface CandidateRequest { readonly query: string + /** True only for the open `@"path with spaces` grammar. */ + readonly quoted?: boolean readonly position: TriggerPosition readonly signal: AbortSignal } @@ -204,6 +210,8 @@ export interface ConsumeTokenRequest { export interface InsertTextRequest { /** Literal replacement for the trigger token span (e.g. `/name `). */ readonly text: string + /** Re-run trigger detection after insertion (directory descent). */ + readonly continue?: boolean readonly span: TokenSpan } diff --git a/packages/client/ui-slash/tests/core-detect.spec.ts b/packages/client/ui-slash/tests/core-detect.spec.ts index 6d3326b5d8..cf84875960 100644 --- a/packages/client/ui-slash/tests/core-detect.spec.ts +++ b/packages/client/ui-slash/tests/core-detect.spec.ts @@ -91,6 +91,17 @@ describe('detectTrigger guard tiers', () => { }) describe('detectTrigger span and query', () => { + it('keeps an open quoted @file token active across spaces', () => { + const draft = 'read @"docs/design notes' + expect(atEnd(draft)).toMatchObject({ + trigger: '@', + query: 'docs/design notes', + quoted: true, + position: 'inline', + span: { start: 5, end: draft.length }, + }) + }) + it('spans trigger char to caret with a placeholder draftRev', () => { const hit = detectTrigger('say /goal', 9, plain) expect(hit?.span).toEqual({ start: 4, end: 9, draftRev: 0 }) diff --git a/packages/client/ui-slash/tests/core-menu.spec.ts b/packages/client/ui-slash/tests/core-menu.spec.ts index 28cd009948..55a1d3ef70 100644 --- a/packages/client/ui-slash/tests/core-menu.spec.ts +++ b/packages/client/ui-slash/tests/core-menu.spec.ts @@ -8,6 +8,7 @@ import { exactMatch, MENU_CLOSED, menuReduce, seedGroups } from '../src/core/men const hit = (query = ''): TriggerHit => ({ trigger: '/', query, + quoted: false, position: 'leading', span: { start: 0, end: 1 + query.length, draftRev: 1 }, }) diff --git a/packages/client/ui-slash/tests/menu-view.spec.tsx b/packages/client/ui-slash/tests/menu-view.spec.tsx index d9b6a08a88..d81cb698c4 100644 --- a/packages/client/ui-slash/tests/menu-view.spec.tsx +++ b/packages/client/ui-slash/tests/menu-view.spec.tsx @@ -14,6 +14,7 @@ import { MenuView } from '../src/client/MenuView.tsx' const hit: TriggerHit = { trigger: '/', query: 'g', + quoted: false, position: 'leading', span: { start: 0, end: 2, draftRev: 1 }, } @@ -60,6 +61,31 @@ describe('MenuView', () => { expect(screen.queryByText('Loading skill…')).not.toBeNull() }) + it('renders contiguous candidate sections once without changing option indexes', () => { + const { onPick } = mount(openState({ + groups: [{ + source: 'reference', + status: 'ready', + items: [ + { name: 'Folder · src/', section: '文件与文件夹' }, + { name: 'File · README.md', section: '文件与文件夹' }, + { name: 'Session · Research', section: 'Session 对话' }, + ], + }], + highlight: { source: 'reference', index: 0 }, + })) + expect(screen.getAllByText('文件与文件夹')).toHaveLength(1) + expect(screen.getAllByText('Session 对话')).toHaveLength(1) + const options = screen.getAllByRole('option') + expect(options.map(option => option.textContent)).toEqual([ + 'Folder · src/', + 'File · README.md', + 'Session · Research', + ]) + fireEvent.mouseDown(options[2]!) + expect(onPick).toHaveBeenCalledWith('reference', 2) + }) + it('exposes the highlight via aria-activedescendant and aria-selected', () => { mount(openState({ highlight: { source: 'command', index: 1 } })) const listbox = screen.getByRole('listbox') diff --git a/packages/client/ui-slash/tsconfig.json b/packages/client/ui-slash/tsconfig.json index a3002d4981..6057746631 100644 --- a/packages/client/ui-slash/tsconfig.json +++ b/packages/client/ui-slash/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../runtime" }, + { + "path": "../../context/file-reference" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md deleted file mode 100644 index 7a70add139..0000000000 --- a/packages/client/ui-subagent/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# @deepseek-ai/dsh-client-ui-subagent - -English | [中文](README.zh.md) - -Subagent reference source, browser half: registers the `@`-trigger `subagent` source into `ctx.slash`. Candidates are zero-RPC — filtered from the root `ctx.sessions.list` snapshot captured at registration (children of the per-call projection's session: `parentId` matches, `running`, `displayTitle` contains the query); picking a candidate lands the literal `@label ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` projects both faces as `@label` — the model serialization stays the raw label until the `@` consumption feature defines a model representation. The source implements no `matchSpace`/`matchEnter` hooks — subagent references never enter command adjudication and ride ordinary prompts into the default sink. - -A session with no running children is simply candidate-less. This phase ships "menu + reference text" only; what consuming an `@label` means (steering the child, resuming a disposed one) is future business work. - -The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect. - -## Model Experience - -### Subagent label text in the user prompt - -#### What the model sees - -A picked candidate lands the literal `@label` (the child session's display title) in the draft; the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side resolution. No consumption semantics exist yet: the model sees plain text and interprets it unaided. - -#### Token effect - -Conditional and tiny: only a pick (or hand-typing the same text) adds the label's characters to that one user message. Menu browsing adds zero model tokens (candidates never leave the browser). - -#### KV Cache effect - -Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens. - -## Known Limitations and Deferred Work - -- **`@` consumption semantics are unbuilt** — the reference is inert text; wiring it to steer/message the named child (and whether resuming a disposed child is allowed) awaits its own design decision in the ledger. -- **Candidates are running children only** — completed or disposed subagents never appear, and the roster is the scoped session's direct children (no grandchildren, no cross-session agents). -- **Labels are display titles, not stable ids** — two children sharing a display title produce indistinguishable references, and a title change orphans previously inserted text. Acceptable while references are inert; a consumption feature must bind to session ids. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md deleted file mode 100644 index 2d8ee677c7..0000000000 --- a/packages/client/ui-subagent/README.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# @deepseek-ai/dsh-client-ui-subagent - -[English](README.md) | 中文 - -subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source 注册进 `ctx.slash`。候选零 RPC——从注册时捕获的根 `ctx.sessions.list` 快照过滤(每次调用的投影所指会话的子会话:`parentId` 匹配、`running`、`displayTitle` 包含 query);pick 一个候选会把字面文本 `@label ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 把两种投影都产出为 `@label`——在 `@` 消费功能定义模型表示之前,模型序列化保持原始 label。source 不实现 `matchSpace`/`matchEnter` 钩子——subagent 引用永不进入命令裁决,随普通提示词落入 default sink。 - -没有运行中子会话的会话就是没有候选。本阶段只交付「菜单 + 引用文本」;消费一个 `@label` 意味着什么(对子会话做 steering(中途引导)、恢复已 dispose 的子会话)是未来的业务工作。 - -`/client` 导出表层只有插件主体(`apply`/`inject`);source 对象是注册 effect 的内部实现。 - -## 模型体验 - -### 用户提示词中的 subagent label 文本 - -#### 模型所见 - -被 pick 的候选会把字面文本 `@label`(子会话的显示标题)落进草稿;该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧解析。目前不存在任何消费语义:模型看到的是纯文本,只能自行解读。 - -#### Token 影响 - -有条件且极小:只有 pick(或手动键入相同文本)会把 label 的字符加进那一条用户消息。浏览菜单增加零模型 token(候选永不离开浏览器)。 - -#### KV Cache 影响 - -仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。 - -## 已知限制与暂缓事项 - -- **`@` 消费语义尚未构建**:引用只是惰性文本;把它接到对指名子会话的 steering/发消息(以及是否允许恢复已 dispose 的子会话),等待台账中它自己的设计决策。 -- **候选只有运行中的子会话**:已完成或已 dispose 的 subagent 永不出现,roster 只含 scope 所指会话的直接子会话(不含孙辈,不含跨会话 agent)。 -- **label 是显示标题,不是稳定 id**:两个子会话共用一个显示标题时,产生的引用无法区分;标题变更会使先前插入的文本失去指向。引用还是惰性文本时可以接受;消费功能必须绑定到会话 id。 diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts deleted file mode 100644 index 3ad1543c68..0000000000 --- a/packages/client/ui-subagent/src/client/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Subagent reference plugin, browser half: registers the '@' source — - * candidates filtered from the session list snapshot's running children - * (zero RPC; the list rides the plugin's root-context sessions service, the - * scoped session comes from the per-call projection), pick inserts the - * literal `@label ` text (decision 21: the draft carries plain text, chip - * visuals are derived by scanning against the source lexicon, and the - * prompt ships the same literal). Consumption semantics stay with future - * business work (design ledger). No adjudication hooks: subagent - * references never enter command adjudication. - */ -import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' - -/** Required services: the slash registry + the session list face the source closes over. */ -export const inject = ['slash', 'sessions'] - -/** - * Client plugin body: register the '@' subagent source over the root session list. - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - const sessions = ctx.get('sessions') as SessionsService - // Child labels live on the session list (parentId lineage + displayTitle), - // not the conversation snapshot — the list store is the zero-RPC candidate feed. - const childLabels = (session: ClientSessionContext, query: string): string[] => { - const { byId } = sessions.list.getSnapshot() - return Object.values(byId) - .filter(child => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query)) - .map(child => child.displayTitle) - } - const source: SlashSource = { - trigger: '@', - name: 'subagent', - candidates(session, { query }) { - return Promise.resolve(childLabels(session, query).map(name => ({ name }))) - }, - lexicon(session) { - // The list snapshot is always warm — the full running-children roster. - return childLabels(session, '') - }, - onPick({ candidate }) { - // Decision 21: plain-text reference — the literal lands in the draft - // and ships to the model verbatim (trailing space closes the token). - // Legacy path (decision 21), retained for the removal cut, no longer reached: - // return { insert: { source: 'subagent', ref: candidate.name, label: candidate.name, clipboardText: `@${candidate.name}` } } - return { text: `@${candidate.name} ` } - }, - codec: { - clipboardText: ref => `@${ref}`, - // TODO: serialize returns the raw label until the '@' consumption - // feature defines a model representation (design ledger). - serialize: ref => Promise.resolve(`@${ref}`), - }, - } - const slash = ctx.get('slash') as SlashServiceContract - ctx.effect(() => slash.registerSource(source), 'ui-subagent: @ source') -} diff --git a/packages/client/ui-subagent/src/css-modules.d.ts b/packages/client/ui-subagent/src/css-modules.d.ts deleted file mode 100644 index bc5e482353..0000000000 --- a/packages/client/ui-subagent/src/css-modules.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module '*.module.css' { - const classes: Record - export default classes -} - -declare module '*.css' diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts deleted file mode 100644 index fc74470406..0000000000 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * ui-subagent browser half: source registration (duplicate-name proof) + - * fiber-teardown removal (HMR safety) against the real SlashService, then - * the source behavior contract driven directly on the captured source with - * real ClientSessionContext projections — zero-RPC candidates from the root - * session list (running children of the projected session, label-contains - * filtering, childless session → empty), the synchronous lexicon roster, - * pick → plain-text outcome (decision 21), and the reference codec's two - * projections. Direct driving is deliberate: this spec owns only the - * source's own contract. - */ -import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' -import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/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, inject } from '../src/client/index.ts' - -function summary(partial: Partial & { id: SessionId }): SessionSummary { - return { - displayTitle: partial.id, - running: false, - updatedAt: 0, - ...partial, - } as SessionSummary -} - -const sid = (id: string) => id as SessionId - -/** Fake root sessions face: the list snapshot the source closes over. */ -function sessionsWith(sessions: SessionSummary[]) { - const byId: Record = {} - for (const s of sessions) byId[s.id] = s - const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState - return { list: { getSnapshot: () => snapshot } } -} - -/** Boot the plugin over fake slash/sessions faces; returns the captured source. */ -async function bench(sessions: SessionSummary[]): Promise { - const ctx = new Context() - let captured: SlashSource | undefined - ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) - ctx.provide('sessions', sessionsWith(sessions)) - await ctx.plugin({ inject: [...inject], apply }).await() - return captured! -} - -const FAMILY: SessionSummary[] = [ - summary({ id: sid('parent'), displayTitle: 'parent', running: true }), - summary({ id: sid('c1'), parentId: sid('parent'), displayTitle: 'worker-1', running: true }), - summary({ id: sid('c2'), parentId: sid('parent'), displayTitle: 'worker-2', running: true }), - // Filtered out: not running / other parent / label miss. - summary({ id: sid('c3'), parentId: sid('parent'), displayTitle: 'worker-3', running: false }), - summary({ id: sid('c4'), parentId: sid('other'), displayTitle: 'worker-4', running: true }), - summary({ id: sid('c5'), parentId: sid('parent'), displayTitle: 'scout', running: true }), -] - -const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) }) - -const req = (query: string) => - ({ query, position: 'inline' as const, signal: new AbortController().signal }) - -describe('apply', () => { - it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions']) - }) - - it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => { - const ctx = new Context() - await ctx.plugin(SlashService).await() - ctx.provide('sessions', sessionsWith(FAMILY)) - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - const slash = ctx.get('slash') as SlashService - const rival = { - trigger: '@' as const, - name: 'subagent', - candidates: () => Promise.resolve([]), - onPick: () => undefined, - } - // Live registration holds the (trigger, name) seat… - expect(() => slash.registerSource(rival)).toThrow(/already registered/) - // …and fiber teardown releases it. - await fiber.dispose() - expect(() => slash.registerSource(rival)).not.toThrow() - }) -}) - -describe('candidates', () => { - it('returns running children of the projected session, filtered by label containment', async () => { - const source = await bench(FAMILY) - await expect(source.candidates(proj('parent'), req('worker'))).resolves.toEqual([ - { name: 'worker-1' }, { name: 'worker-2' }, - ]) - }) - - it('matches every running child on an empty query (containment, not prefix)', async () => { - const source = await bench(FAMILY) - await expect(source.candidates(proj('parent'), req(''))).resolves.toEqual([ - { name: 'worker-1' }, { name: 'worker-2' }, { name: 'scout' }, - ]) - }) - - it('is candidate-less for a session with no children', async () => { - const source = await bench(FAMILY) - await expect(source.candidates(proj('childless'), req(''))).resolves.toEqual([]) - }) -}) - -describe('lexicon', () => { - it('synchronously serves the projected session\'s full running-children roster', async () => { - const source = await bench(FAMILY) - expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout']) - expect(source.lexicon!(proj('childless'))).toEqual([]) - }) -}) - -describe('pick and codec', () => { - it('onPick returns the literal @label text with a closing space (decision 21)', async () => { - const source = await bench(FAMILY) - const outcome = source.onPick({ - candidate: { name: 'worker-1' }, - session: proj('parent'), - position: 'inline', - via: 'menu', - span: { start: 4, end: 8, draftRev: 3 }, - }) - expect(outcome).toEqual({ text: '@worker-1 ' }) - }) - - it('codec projects clipboard `@label` and serializes the same raw label this phase', async () => { - const source = await bench(FAMILY) - expect(source.codec!.clipboardText('worker-1')).toBe('@worker-1') - await expect(source.codec!.serialize('worker-1', new AbortController().signal)) - .resolves.toBe('@worker-1') - }) -}) - -describe('adjudication', () => { - it('never participates: no matchSpace/matchEnter hooks on the subagent source', async () => { - const source = await bench(FAMILY) - expect('matchSpace' in source && source.matchSpace !== undefined).toBe(false) - expect('matchEnter' in source && source.matchEnter !== undefined).toBe(false) - }) -}) diff --git a/packages/client/ui-subagent/tsdown.config.ts b/packages/client/ui-subagent/tsdown.config.ts deleted file mode 100644 index 71078e15a2..0000000000 --- a/packages/client/ui-subagent/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-ui-subagent', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/context/file-reference-local/README.i18n.yaml b/packages/context/file-reference-local/README.i18n.yaml new file mode 100644 index 0000000000..475baf10db --- /dev/null +++ b/packages/context/file-reference-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/context/file-reference-local/README.md +README.md: 67b07eef4b59fdcc5e21104cac4628feb1c15f4e +README.zh.md: beded13250daf041294e4e2656d0e1374407ff94 diff --git a/packages/context/file-reference-local/README.md b/packages/context/file-reference-local/README.md new file mode 100644 index 0000000000..67b07eef4b --- /dev/null +++ b/packages/context/file-reference-local/README.md @@ -0,0 +1,45 @@ +# `@deepseek-ai/dsh-file-reference-local` + +English | [中文](README.zh.md) + +Local-filesystem implementation of `ctx.fileReferences`. It maintains one bounded `WorkspaceFileSearch` per agent, rooted at that session's `cwd` and falling back to the host process cwd. The index ranks direct directory listings for queries containing `/`, otherwise fuzzy-ranks a bounded recursive index; it never follows directory symlinks. + +Tool-result events invalidate the addressed agent's reusable index so later completion observes likely workspace mutations. Agent disposal releases that index and its scoped prompt contribution; plugin disposal awaits every prompt fiber and releases all cached searches. + +## Configuration + +| Key | Default | Contract | +|---|---:|---| +| `maxResults` | `20` | Maximum ranked candidates returned for one query. | +| `maxEntries` | `10000` | Maximum files and directories indexed per agent workspace. | +| `excludedDirectories` | `[".git", "node_modules"]` | Directory basenames omitted from traversal and candidates. | + +Every numeric value must be a positive safe integer. Excluded names must be non-empty basenames without `/` or `\`. + +## Model Experience + +### File-reference guidance when `read` is available + +#### What the model sees + +When the addressed agent has an effective `read` tool, the provider contributes this stable system-prompt section: + +##### File-reference instruction + +```markdown +Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. +``` + +#### Token effect + +Conditional and fixed: the one sentence is present while `read` is visible to the addressed agent; candidate lookup itself adds no tokens, and a selected path contributes only its ordinary user-message characters. + +#### KV Cache effect + +The stable sentence joins the system-prompt prefix. Mounting or removing this provider, or changing whether `read` is visible, changes that prefix; queries, candidates, and index invalidations do not. + +## Known Limitations and Deferred Work + +- **Host-local namespace** — the provider scans the Harness host filesystem, so remote or virtual `read` implementations require a provider whose namespace matches the tool. +- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear. +- **No ignore-file semantics** — `.gitignore` and other project ignore files do not influence discovery; only configured directory basenames are excluded. diff --git a/packages/context/file-reference-local/README.zh.md b/packages/context/file-reference-local/README.zh.md new file mode 100644 index 0000000000..beded13250 --- /dev/null +++ b/packages/context/file-reference-local/README.zh.md @@ -0,0 +1,45 @@ +# `@deepseek-ai/dsh-file-reference-local` + +[English](README.md) | 中文 + +`ctx.fileReferences` 的本地文件系统实现。它为每个 agent(智能体)维护一个有界的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根目录;缺少该值时回退到宿主进程的 cwd。查询包含 `/` 时,索引会对直接列出的目录项排序;否则会对有界递归索引进行模糊排序。索引永远不会跟随目录符号链接。 + +工具结果事件会使指定 agent 的可复用索引失效,使后续补全能够反映工作区中可能发生的变更。agent 的 dispose(资源释放)会释放该索引及其作用域内的提示词贡献;插件 dispose 会等待所有提示词 fiber,并释放全部缓存的搜索器。 + +## 配置 + +| 配置键 | 默认值 | 契约 | +|---|---:|---| +| `maxResults` | `20` | 单次查询返回的候选项最大数量。 | +| `maxEntries` | `10000` | 每个 agent 工作区建立索引的文件和目录最大数量。 | +| `excludedDirectories` | `[".git", "node_modules"]` | 遍历和候选项中排除的目录基名。 | + +所有数值都必须是正的安全整数。排除名称必须是非空基名,且不能包含 `/` 或 `\`。 + +## 模型体验 + +### `read` 可用时的文件引用指引 + +#### 模型看到什么 + +当指定 agent 有实际生效的 `read` 工具时,提供方会贡献以下稳定的系统提示词段: + +##### 文件引用指令 + +```markdown +Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. +``` + +#### Token 影响 + +该影响有条件且固定:只要 `read` 对指定 agent 可见,这一句就会存在;候选查询本身不增加 token,所选路径只会贡献普通用户消息中的对应字符。 + +#### KV 缓存影响 + +该稳定句子会加入系统提示词前缀。挂载或移除此提供方,或者改变 `read` 是否可见,都会改变该前缀;查询、候选项和索引失效不会改变前缀。 + +## 已知限制与暂缓事项 + +- **宿主本地命名空间**:提供方扫描 Harness 宿主的文件系统,因此远程或虚拟 `read` 实现需要使用命名空间与该工具一致的提供方。 +- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。 +- **没有忽略文件语义**:`.gitignore` 和其他项目忽略文件不会影响发现;系统只排除已配置的目录基名。 diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json new file mode 100644 index 0000000000..feb4cecfbf --- /dev/null +++ b/packages/context/file-reference-local/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-file-reference-local", + "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", + "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" + }, + "./search": { + "types": "./lib/types/search.d.ts", + "default": "./lib/types/search.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/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-file-reference": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/context/file-reference-local/src/index.ts b/packages/context/file-reference-local/src/index.ts new file mode 100644 index 0000000000..60fa744058 --- /dev/null +++ b/packages/context/file-reference-local/src/index.ts @@ -0,0 +1,140 @@ +/** + * Local-filesystem implementation of `ctx.fileReferences`. + * + * @module @deepseek-ai/dsh-file-reference-local + */ + +import { Context } from 'cordis' +import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import FileReferenceService, { + FILE_REFERENCE_PROMPT, + type FileReferenceCandidate, +} from '@deepseek-ai/dsh-file-reference' +import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-tools' +import { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, + WorkspaceFileSearch, + type FileSearchConfig, +} from './search.ts' + +export { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, + WorkspaceFileSearch, +} from './search.ts' +export type { FileSearchConfig } from './search.ts' +export { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference' +export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' + +/** Local file-reference discovery configuration. */ +export interface Config { + /** Maximum ranked candidates returned for one query. */ + maxResults?: number + /** Maximum indexed files and directories per agent workspace. */ + maxEntries?: number + /** Directory basenames never traversed or offered. */ + excludedDirectories?: string[] +} + +/** Local-filesystem owner of the file-reference discovery service. */ +export class LocalFileReferenceService extends FileReferenceService { + static inject = ['agents'] + static Config: z = z.object({ + maxResults: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS), + maxEntries: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES), + excludedDirectories: z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]), + }) + + private readonly config: FileSearchConfig + private readonly searches = new Map() + private readonly promptFibers = new Map>() + private readonly promptDisposals = new Set>() + + constructor(ctx: Context, config: Config = {}) { + super(ctx) + this.config = { + maxResults: config.maxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, + maxEntries: config.maxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, + excludedDirectories: config.excludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + } + validateConfig(this.config) + + const installPrompt = (agent: Agent): void => { + if (this.promptFibers.has(agent)) return + const fiber = agent.ctx.inject(['systemPrompt', 'tools'], (scope) => { + scope.systemPrompt.section({ + name: 'context:file-reference', + order: 99, + text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT, + }) + }) + this.promptFibers.set(agent, fiber) + } + const disposePrompt = (agent: Agent): void => { + const fiber = this.promptFibers.get(agent) + if (fiber === undefined) return + this.promptFibers.delete(agent) + const task = fiber.dispose().catch((error: unknown) => { + ctx.logger.warn(`file-reference-local: prompt cleanup failed: ${error instanceof Error ? error.message : String(error)}`) + }) + this.promptDisposals.add(task) + void task.finally(() => { + this.promptDisposals.delete(task) + }) + } + for (const agent of ctx.agents.list()) installPrompt(agent) + ctx.on('agent/created', installPrompt) + ctx.on('agent/disposed', (agent) => { + this.searches.get(agent)?.dispose() + this.searches.delete(agent) + disposePrompt(agent) + }) + ctx.on('session/event', (session, event) => { + if (event.type !== 'tool/result') return + const agent = ctx.agents.get(session.id) + if (agent !== undefined) this.searches.get(agent)?.invalidate() + }) + ctx.effect(() => async () => { + for (const search of this.searches.values()) search.dispose() + this.searches.clear() + const promptFibers = [...this.promptFibers.values()] + this.promptFibers.clear() + await Promise.all([ + ...promptFibers.map(fiber => fiber.dispose()), + ...this.promptDisposals, + ]) + }, 'file-reference-local: search cache') + } + + override list( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise { + let search = this.searches.get(agent) + if (search === undefined) { + search = new WorkspaceFileSearch(agent.session.header.cwd ?? process.cwd(), this.config) + this.searches.set(agent, search) + } + return search.list(query, signal) + } +} + +function validateConfig(config: FileSearchConfig): void { + if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) { + throw new Error('file-reference-local: maxResults must be a positive safe integer') + } + if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) { + throw new Error('file-reference-local: maxEntries must be a positive safe integer') + } + if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) { + throw new Error('file-reference-local: excludedDirectories entries must be non-empty directory basenames') + } +} + +export default LocalFileReferenceService diff --git a/packages/context/file-reference-local/src/invariant.ts b/packages/context/file-reference-local/src/invariant.ts new file mode 100644 index 0000000000..c66f64e520 --- /dev/null +++ b/packages/context/file-reference-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-file-reference-local`. + * @module @deepseek-ai/dsh-file-reference-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-file-reference-local' + +/** Cordis companion plugin name. */ +export const name = 'file-reference-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: per-agent indexes are private advisory caches whose + * invalidation and disposal are observed directly through service tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/ui/tui/src/file-autocomplete.ts b/packages/context/file-reference-local/src/search.ts similarity index 78% rename from packages/ui/tui/src/file-autocomplete.ts rename to packages/context/file-reference-local/src/search.ts index 23a3a7c1fa..a0257a5d33 100644 --- a/packages/ui/tui/src/file-autocomplete.ts +++ b/packages/context/file-reference-local/src/search.ts @@ -1,13 +1,16 @@ /** - * Host-workspace discovery for TUI `@file` completion. The index contains - * paths only: selected values remain ordinary prompt text and file contents - * stay behind the model-facing `read` tool. + * Host-workspace discovery for `@file` completion. The index contains paths + * only: selected values remain ordinary prompt text and file contents stay + * behind the model-facing `read` tool. * - * @module @deepseek-ai/dsh-tui/file-autocomplete + * @module @deepseek-ai/dsh-file-reference-local/search */ import { lstat, readdir } from 'node:fs/promises' import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference' + +export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' /** Default maximum file and directory candidates rendered for one query. */ export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20 @@ -16,7 +19,7 @@ export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000 /** Directory basenames omitted from traversal unless the deployment overrides them. */ export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const -/** Resolved limits and exclusions for one TUI workspace index. */ +/** Resolved limits and exclusions for one workspace index. */ export interface FileSearchConfig { /** Maximum ranked candidates returned for one query. */ maxResults: number @@ -26,28 +29,10 @@ export interface FileSearchConfig { excludedDirectories: readonly string[] } -/** One path-only completion candidate inside the session cwd. */ -export interface FileSearchCandidate { - /** User-facing path accepted by the normal prompt and filesystem tools. */ - path: string - /** Directories keep completion open; files finish the mention. */ - kind: 'file' | 'directory' -} - -/** Active `@` token ending at the editor cursor. */ -export interface ActiveAtToken { - /** Complete token replaced when the user accepts a completion. */ - prefix: string - /** Path query after `@` or `@"`. */ - query: string - /** Whether the user opened a quoted path. */ - quoted: boolean -} - -interface IndexedPath extends FileSearchCandidate {} +interface IndexedPath extends FileReferenceCandidate {} interface RankedPath { - candidate: FileSearchCandidate + candidate: FileReferenceCandidate score: number } @@ -56,43 +41,6 @@ interface IndexGeneration { promise: Promise } -/** - * Extract an `@path` or `@"path with spaces` token at the cursor. An `@` - * inside another token, such as an email address, is not a completion trigger. - * @param line - current editor line. - * @param cursorCol - cursor column within that line. - * @returns the active token, or `undefined` outside an `@` token. - */ -export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined { - const beforeCursor = line.slice(0, cursorCol) - const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor) - if (quoted?.[1] !== undefined && quoted[2] !== undefined) { - return { prefix: quoted[1], query: quoted[2], quoted: true } - } - const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor) - if (plain?.[1] === undefined || plain[2] === undefined) return undefined - return { prefix: plain[1], query: plain[2], quoted: false } -} - -/** - * Format a selected path as prompt text. Whitespace uses Pi's quoted - * `@"path"` grammar; directories retain a trailing slash so completion can - * descend another level. - * @param candidate - selected file or directory. - * @param preserveQuote - retain an explicitly opened quote even when unnecessary. - * @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely. - */ -export function formatFileMention( - candidate: FileSearchCandidate, - preserveQuote: boolean, -): string | undefined { - const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path - if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined - const quoted = preserveQuote || /\s/u.test(path) - if (!quoted) return `@${path}` - return `@"${path}"` -} - /** * Cancellable, reusable fuzzy index rooted at one agent working directory. * Directory-scoped queries list live state; bare fuzzy queries share one @@ -125,7 +73,7 @@ export class WorkspaceFileSearch { * @param signal - cancels this caller's wait without killing an index shared by a newer query. * @returns at most `maxResults` deterministic candidates. */ - async list(rawQuery: string, signal: AbortSignal): Promise { + async list(rawQuery: string, signal: AbortSignal): Promise { signal.throwIfAborted() if (this.disposed) return [] const query = rawQuery.replaceAll('\\', '/') @@ -203,12 +151,12 @@ export class WorkspaceFileSearch { displayDirectory: string, fragment: string, signal: AbortSignal, - ): Promise { + ): Promise { if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return [] const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal) if (absolute === undefined) return [] const entries = await readDirectory(absolute, signal) - const candidates: FileSearchCandidate[] = [] + const candidates: FileReferenceCandidate[] = [] for (const entry of entries) { if (entry.name.startsWith('.') && !fragment.startsWith('.')) continue if (entry.isDirectory()) { @@ -269,10 +217,10 @@ function visibleForGlobalQuery(path: string, query: string): boolean { } function rankCandidates( - candidates: readonly FileSearchCandidate[], + candidates: readonly FileReferenceCandidate[], query: string, limit: number, -): FileSearchCandidate[] { +): FileReferenceCandidate[] { const ranked: RankedPath[] = [] for (const candidate of candidates) { const score = scoreCandidate(candidate, query) @@ -286,7 +234,7 @@ function rankCandidates( return ranked.slice(0, limit).map(entry => entry.candidate) } -function scoreCandidate(candidate: FileSearchCandidate, query: string): number | undefined { +function scoreCandidate(candidate: FileReferenceCandidate, query: string): number | undefined { if (query === '') return 0 const path = candidate.path.toLowerCase() const name = path.slice(path.lastIndexOf('/') + 1) @@ -312,7 +260,7 @@ function subsequenceScore(target: string, query: string): number | undefined { return Math.max(0, 100 - gap) } -function kindRank(kind: FileSearchCandidate['kind']): number { +function kindRank(kind: FileReferenceCandidate['kind']): number { return kind === 'directory' ? 0 : 1 } diff --git a/packages/context/file-reference-local/tests/invariant.spec.ts b/packages/context/file-reference-local/tests/invariant.spec.ts new file mode 100644 index 0000000000..3b06290d45 --- /dev/null +++ b/packages/context/file-reference-local/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as FileReferenceLocalInvariant from '../src/invariant.ts' + +describe('invariant companion', () => { + it('registers the provider cache ownership under its package name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(FileReferenceLocalInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/ui/tui/tests/file-autocomplete.spec.ts b/packages/context/file-reference-local/tests/search.spec.ts similarity index 97% rename from packages/ui/tui/tests/file-autocomplete.spec.ts rename to packages/context/file-reference-local/tests/search.spec.ts index 53dd1f4f1a..98bdd8cf66 100644 --- a/packages/ui/tui/tests/file-autocomplete.spec.ts +++ b/packages/context/file-reference-local/tests/search.spec.ts @@ -6,7 +6,7 @@ import { activeAtToken, formatFileMention, WorkspaceFileSearch, -} from '../src/file-autocomplete.ts' +} from '../src/search.ts' const searches: WorkspaceFileSearch[] = [] const roots: string[] = [] @@ -48,7 +48,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) -describe('TUI file autocomplete grammar', () => { +describe('file-reference grammar', () => { it('recognizes boundary and quoted mentions without treating emails as references', () => { expect(activeAtToken('@src/tu', 7)).toEqual({ prefix: '@src/tu', query: 'src/tu', quoted: false }) expect(activeAtToken('read @"docs/design n', 20)).toEqual({ @@ -65,6 +65,8 @@ describe('TUI file autocomplete grammar', () => { expect(formatFileMention({ path: 'src', kind: 'directory' }, false)).toBe('@src/') expect(formatFileMention({ path: 'docs/design notes.md', kind: 'file' }, false)) .toBe('@"docs/design notes.md"') + expect(formatFileMention({ path: 'docs/design notes', kind: 'directory' }, false)) + .toBe('@"docs/design notes/') expect(formatFileMention({ path: 'README.md', kind: 'file' }, true)).toBe('@"README.md"') expect(formatFileMention({ path: 'bad\nname', kind: 'file' }, false)).toBeUndefined() expect(formatFileMention({ path: 'bad "name".md', kind: 'file' }, false)).toBeUndefined() diff --git a/packages/context/file-reference-local/tests/service.spec.ts b/packages/context/file-reference-local/tests/service.spec.ts new file mode 100644 index 0000000000..f1c7bd59bf --- /dev/null +++ b/packages/context/file-reference-local/tests/service.spec.ts @@ -0,0 +1,161 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference' +import LocalFileReferenceService, { WorkspaceFileSearch } from '../src/index.ts' + +const roots: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + return ctx +} + +async function stubAgent( + ctx: Context, + id = 'file-reference-agent', + includeCwd = true, +): Promise<{ agent: Agent; dispose: () => void }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-reference-service-')) + roots.push(root) + await writeFile(join(root, 'README.md'), 'readme') + const session = ctx.sessions.create(SessionId(id), { meta: includeCwd ? { cwd: root } : {} }) + const agent = { + id: session.id, + options: {}, + session, + status: 'idle', + ctx, + followup: () => AgentMessageId('followup'), + queue: () => AgentMessageId('queue'), + steer: () => AgentMessageId('steer'), + inject: () => AgentMessageId('inject'), + send: () => AgentMessageId('send'), + cancel() {}, + whenIdle: () => Promise.resolve(), + } as Agent + return { agent, dispose: ctx.agents.register(agent) } +} + +describe('LocalFileReferenceService', () => { + it('serves the addressed workspace and installs read-tool guidance for existing agents', async () => { + const ctx = await harness() + const { agent } = await stubAgent(ctx) + const fiber = ctx.plugin(LocalFileReferenceService, { + maxResults: 5, + maxEntries: 100, + excludedDirectories: ['.git'], + }) + await fiber + await expect(ctx.fileReferences.list(agent, 'README', new AbortController().signal)) + .resolves.toEqual([{ path: 'README.md', kind: 'file' }]) + expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT) + + ctx.tools.register(defineContentToolFixture({ + name: 'read', + description: 'read a file', + parameters: {}, + execute: () => Promise.resolve([]), + })) + expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(FILE_REFERENCE_PROMPT) + await fiber.dispose() + expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT) + }) + + it('invalidates cached searches after tool results and disposes them with the agent', async () => { + const ctx = await harness() + const { agent, dispose } = await stubAgent(ctx) + const invalidate = vi.spyOn(WorkspaceFileSearch.prototype, 'invalidate') + const close = vi.spyOn(WorkspaceFileSearch.prototype, 'dispose') + await ctx.plugin(LocalFileReferenceService) + await ctx.fileReferences.list(agent, 'README', new AbortController().signal) + + ctx.emit('session/event', agent.session, { type: 'tool/result' } as never) + expect(invalidate).toHaveBeenCalledOnce() + ctx.emit('session/event', agent.session, { type: 'assistant/message' } as never) + expect(invalidate).toHaveBeenCalledOnce() + const orphan = ctx.sessions.create(SessionId('file-reference-orphan')) + ctx.emit('session/event', orphan, { type: 'tool/result' } as never) + expect(invalidate).toHaveBeenCalledOnce() + + dispose() + expect(close).toHaveBeenCalledOnce() + ctx.emit('agent/disposed', agent) + }) + + it('installs guidance for agents announced after the service and validates deployment tunables', async () => { + const ctx = await harness() + await ctx.plugin(LocalFileReferenceService) + const { agent } = await stubAgent(ctx) + await expect(ctx.fileReferences.list(agent, '', new AbortController().signal)) + .resolves.toEqual([{ path: 'README.md', kind: 'file' }]) + + const badResults = await harness() + expect(() => new LocalFileReferenceService(badResults, { maxResults: 0 })).toThrow('maxResults') + const badEntries = await harness() + expect(() => new LocalFileReferenceService(badEntries, { maxEntries: 1.5 })).toThrow('maxEntries') + const badExclusion = await harness() + expect(() => new LocalFileReferenceService(badExclusion, { excludedDirectories: ['nested/name'] })) + .toThrow('excludedDirectories') + const fractionalResults = await harness() + expect(() => new LocalFileReferenceService(fractionalResults, { maxResults: 1.5 })).toThrow('maxResults') + const zeroEntries = await harness() + expect(() => new LocalFileReferenceService(zeroEntries, { maxEntries: 0 })).toThrow('maxEntries') + const emptyExclusion = await harness() + expect(() => new LocalFileReferenceService(emptyExclusion, { excludedDirectories: [''] })) + .toThrow('excludedDirectories') + const backslashExclusion = await harness() + expect(() => new LocalFileReferenceService(backslashExclusion, { excludedDirectories: ['nested\\name'] })) + .toThrow('excludedDirectories') + }) + + it('deduplicates lifecycle announcements and falls back to the process cwd', async () => { + const ctx = await harness() + const fiber = ctx.plugin(LocalFileReferenceService) + await fiber + const { agent } = await stubAgent(ctx, 'cwd-fallback', false) + ctx.emit('agent/created', agent) + const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockResolvedValue([]) + await expect(ctx.fileReferences.list(agent, '', new AbortController().signal)).resolves.toEqual([]) + await expect(ctx.fileReferences.list(agent, 'src', new AbortController().signal)).resolves.toEqual([]) + expect(list).toHaveBeenCalledTimes(2) + }) + + it('logs rejected prompt cleanup without failing service teardown', async () => { + const ctx = await harness() + const fiber = ctx.plugin(LocalFileReferenceService) + await fiber + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const inject = vi.spyOn(ctx, 'inject') + .mockReturnValueOnce({ dispose: () => Promise.reject(new Error('error cleanup')) } as never) + // Deliberately proves cleanup tolerates JavaScript callers rejecting non-Error values. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + .mockReturnValueOnce({ dispose: () => Promise.reject('string cleanup') } as never) + const first = await stubAgent(ctx, 'cleanup-one') + const second = await stubAgent(ctx, 'cleanup-two') + expect(inject).toHaveBeenCalledTimes(2) + first.dispose() + second.dispose() + await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: error cleanup') + expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: string cleanup') + }) + await expect(fiber.dispose()).resolves.toBeUndefined() + }) +}) diff --git a/packages/context/file-reference-local/tsconfig.json b/packages/context/file-reference-local/tsconfig.json new file mode 100644 index 0000000000..9aeae5fabf --- /dev/null +++ b/packages/context/file-reference-local/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../file-reference" + } + ] +} diff --git a/packages/context/file-reference/README.i18n.yaml b/packages/context/file-reference/README.i18n.yaml new file mode 100644 index 0000000000..de92c41665 --- /dev/null +++ b/packages/context/file-reference/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/context/file-reference/README.md +README.md: c65c787c2143ba88f2ac9887065b537c23f67ca2 +README.zh.md: 4c0d955bd6804f17ee99a5a65138f39391adfc95 diff --git a/packages/context/file-reference/README.md b/packages/context/file-reference/README.md new file mode 100644 index 0000000000..c65c787c21 --- /dev/null +++ b/packages/context/file-reference/README.md @@ -0,0 +1,22 @@ +# `@deepseek-ai/dsh-file-reference` + +English | [中文](README.zh.md) + +File-reference discovery seam and browser-safe `@file` grammar shared by host-backed user interfaces. `ctx.fileReferences.list(agent, query, signal)` returns path-only file or directory candidates for the addressed agent; concrete providers own namespace access, ranking, caching, and invalidation. + +`activeAtToken()` recognizes an `@path` or open `@"path with spaces` token only at the start of input or after whitespace, so email-like text does not open completion. `formatFileMention()` emits the matching prompt spelling, appends `/` to directory candidates, preserves an explicitly opened quote, and rejects control characters or embedded quotes that the editor grammar cannot represent safely. + +Selecting a candidate does not read or attach file contents. The exported `FILE_REFERENCE_PROMPT` is stable guidance that a provider may install when the addressed agent can call `read`. + +## Model Experience + +Indirectly, through `@deepseek-ai/dsh-file-reference-local`, which conditionally contributes this package's stable file-reference guidance. + +#### KV Cache effect + +The interface and grammar add no request tokens themselves; a provider-owned prompt section determines cache behavior. + +## Known Limitations and Deferred Work + +- **Path candidates are advisory** — the seam does not prove that a later model-facing filesystem tool can access the same namespace; deployments must align the provider with the effective `read` implementation. +- **No file-content reference object** — selected files remain ordinary prompt text and require an explicit model tool call before their contents become model-visible. diff --git a/packages/context/file-reference/README.zh.md b/packages/context/file-reference/README.zh.md new file mode 100644 index 0000000000..4c0d955bd6 --- /dev/null +++ b/packages/context/file-reference/README.zh.md @@ -0,0 +1,22 @@ +# `@deepseek-ai/dsh-file-reference` + +[English](README.md) | 中文 + +文件引用发现 seam,以及供宿主驱动的用户界面共享、可在浏览器中安全使用的 `@file` 语法。`ctx.fileReferences.list(agent, query, signal)` 为指定 agent(智能体)返回仅含路径的文件或目录候选;具体提供方负责命名空间访问、排序、缓存和失效处理。 + +`activeAtToken()` 只在输入开头或空白后识别 `@path` 或尚未闭合的 `@"path with spaces` token,因此类似电子邮件的文本不会打开补全。`formatFileMention()` 会生成与提示词匹配的写法,为目录候选追加 `/`,保留显式打开的引号,并拒绝编辑器语法无法安全表示的控制字符或内嵌引号。 + +选择候选项不会读取或附加文件内容。导出的 `FILE_REFERENCE_PROMPT` 是稳定指引;当指定 agent 可以调用 `read` 时,提供方可以安装该指引。 + +## 模型体验 + +间接影响模型体验:`@deepseek-ai/dsh-file-reference-local` 会按条件贡献本包的稳定文件引用指引。 + +#### KV 缓存影响 + +接口和语法本身不会增加请求 token;缓存行为取决于提供方拥有的提示词段。 + +## 已知限制与暂缓事项 + +- **路径候选仅供参考**:该 seam 不保证后续面向模型的文件系统工具能够访问同一命名空间;部署时必须让提供方与实际生效的 `read` 实现对齐。 +- **没有文件内容引用对象**:所选文件仍是普通提示词文本,其内容必须经过模型显式调用工具后才对模型可见。 diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json new file mode 100644 index 0000000000..be82120f4b --- /dev/null +++ b/packages/context/file-reference/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-file-reference", + "description": "File-reference discovery contract and shared @file grammar", + "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" + }, + "./grammar": { + "types": "./lib/types/grammar.d.ts", + "default": "./lib/types/grammar.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/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/context/file-reference/src/grammar.ts b/packages/context/file-reference/src/grammar.ts new file mode 100644 index 0000000000..3e74248f32 --- /dev/null +++ b/packages/context/file-reference/src/grammar.ts @@ -0,0 +1,55 @@ +/** + * Browser-safe `@file` token grammar shared by terminal and web clients. + * + * @module @deepseek-ai/dsh-file-reference/grammar + */ + +import type { FileReferenceCandidate } from './index.ts' + +/** Active `@` token ending at the editor cursor. */ +export interface ActiveAtToken { + /** Complete token replaced when the user accepts a completion. */ + prefix: string + /** Path query after `@` or `@"`. */ + query: string + /** Whether the user opened a quoted path. */ + quoted: boolean +} + +/** + * Extract an `@path` or `@"path with spaces` token at the cursor. An `@` + * inside another token, such as an email address, is not a completion trigger. + * @param line - current editor line. + * @param cursorCol - cursor column within that line. + * @returns the active token, or `undefined` outside an `@` token. + */ +export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined { + const beforeCursor = line.slice(0, cursorCol) + const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor) + if (quoted?.[1] !== undefined && quoted[2] !== undefined) { + return { prefix: quoted[1], query: quoted[2], quoted: true } + } + const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor) + if (plain?.[1] === undefined || plain[2] === undefined) return undefined + return { prefix: plain[1], query: plain[2], quoted: false } +} + +/** + * Format a selected path as prompt text. Whitespace uses the quoted + * `@"path"` grammar; a quoted directory keeps that quote open after its + * trailing slash so completion can descend another level. + * @param candidate - selected file or directory. + * @param preserveQuote - retain an explicitly opened quote even when unnecessary. + * @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely. + */ +export function formatFileMention( + candidate: FileReferenceCandidate, + preserveQuote: boolean, +): string | undefined { + const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path + if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined + const quoted = preserveQuote || /\s/u.test(path) + if (!quoted) return `@${path}` + if (candidate.kind === 'directory') return `@"${path}` + return `@"${path}"` +} diff --git a/packages/context/file-reference/src/index.ts b/packages/context/file-reference/src/index.ts new file mode 100644 index 0000000000..0ffe25116d --- /dev/null +++ b/packages/context/file-reference/src/index.ts @@ -0,0 +1,51 @@ +/** + * File-reference discovery seam shared by host-backed user interfaces. + * + * @module @deepseek-ai/dsh-file-reference + */ + +import { Service } from 'cordis' +import type { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' + +export { activeAtToken, formatFileMention } from './grammar.ts' +export type { ActiveAtToken } from './grammar.ts' + +/** Model guidance for path-only references selected by a user interface. */ +export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' + +/** One path-only completion candidate inside the target session cwd. */ +export interface FileReferenceCandidate { + /** User-facing path accepted by normal prompts and filesystem tools. */ + path: string + /** Directories keep completion open; files finish the mention. */ + kind: 'file' | 'directory' +} + +declare module 'cordis' { + interface Context { + fileReferences: FileReferenceService + } +} + +/** Host capability for cancellable file-reference discovery. */ +export abstract class FileReferenceService extends Service { + constructor(ctx: Context) { + super(ctx, 'fileReferences') + } + + /** + * List file and directory candidates for one agent's working directory. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ + abstract list( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise +} + +export default FileReferenceService diff --git a/packages/context/file-reference/src/invariant.ts b/packages/context/file-reference/src/invariant.ts new file mode 100644 index 0000000000..bc6c93bdc2 --- /dev/null +++ b/packages/context/file-reference/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-file-reference`. + * @module @deepseek-ai/dsh-file-reference/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-file-reference' + +/** Cordis companion plugin name. */ +export const name = 'file-reference-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the interface retains no candidate or lifecycle + * state; concrete providers own their cache and invalidation relationships. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/context/file-reference/tests/invariant.spec.ts b/packages/context/file-reference/tests/invariant.spec.ts new file mode 100644 index 0000000000..c1fc576a9f --- /dev/null +++ b/packages/context/file-reference/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as FileReferenceInvariant from '../src/invariant.ts' + +describe('invariant companion', () => { + it('registers the stateless seam under its package name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(FileReferenceInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/context/file-reference/tsconfig.json b/packages/context/file-reference/tsconfig.json new file mode 100644 index 0000000000..502dd13b89 --- /dev/null +++ b/packages/context/file-reference/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 224e8300ab..d35e8bd9ff 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -260,6 +260,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'fileReferences', + summary: 'Host capability for cancellable file-reference discovery.', + methods: [ + { + signature: 'abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise', + jsDoc: '/**\n * List file and directory candidates for one agent\'s working directory.\n * @param agent - target agent whose session cwd bounds discovery.\n * @param query - path text following `@` or `@"`.\n * @param signal - caller cancellation.\n * @returns deterministic path-only candidates.\n */', + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -1603,6 +1613,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FileLocation', declaration: 'export interface FileLocation {\n path: string;\n line?: number;\n}', }, + { + name: 'FileReferenceCandidate', + declaration: 'export interface FileReferenceCandidate {\n path: string;\n kind: \'file\' | \'directory\';\n}', + }, { name: 'FinishReason', declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];', diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 05075a9bd4..8a3bd0d36c 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -19,6 +19,10 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, + "./display": { + "types": "./lib/types/display.d.ts", + "default": "./lib/types/display.js" + }, "./src/*": "./src/*", "./package.json": "./package.json", "./surface": { diff --git a/packages/core/session/src/display.ts b/packages/core/session/src/display.ts new file mode 100644 index 0000000000..0a68b83a6f --- /dev/null +++ b/packages/core/session/src/display.ts @@ -0,0 +1,17 @@ +/** + * Browser-safe human projection helpers for durable prompt messages. + * + * @module @deepseek-ai/dsh-session/display + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { PromptMessageData } from './types.ts' + +/** + * Return the human-facing prompt blocks from a durable prompt message. + * @param data - ordinary or steering prompt event data. + * @returns the effective direct prompt, excluding baked prefix context. + */ +export function displayPromptContent(data: PromptMessageData): ContentBlock[] { + return data.envelope?.displayContent ?? data.content +} diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5a6b274b3e..edef069c2a 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -11,9 +11,9 @@ import { isAbsolute } from 'node:path' import { deepFreeze } from '@deepseek-ai/dsh-llm' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' +import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' -import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, PromptMessageData, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts' +import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts' import { snapshotJsonValue } from './json.ts' import { SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' @@ -28,15 +28,7 @@ export type { ChunkRow, StorageRecord } from './chunk-rows.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' - -/** - * Return the human-facing prompt blocks from a durable prompt message. - * @param data - ordinary or steering prompt event data. - * @returns the effective direct prompt, excluding baked prefix context. - */ -export function displayPromptContent(data: PromptMessageData): ContentBlock[] { - return data.envelope?.displayContent ?? data.content -} +export { displayPromptContent } from './display.ts' /** * Find the latest closed message-triggered turn, excluding injection and diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index eb06e14d2d..fd638159d1 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f -README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10 +# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md +README.md: 36584c44feabdfa3da5dd600b630d97f6919c908 +README.zh.md: 4bf9b7151ad4e21c58a84f4aec82d01122ddbfc6 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 43ad70fa8b..36584c44fe 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,7 +14,9 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `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()`. -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). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*`, `skill.*`, and `reference.*` domains expose host command, skill, and reference capabilities to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` and `reference.*` resume cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `reference.files` delegates cancellable path discovery to `ctx.fileReferences`; `reference.sessions` delegates candidate ranking and canonical mention creation to `ctx.sessionReferences`. Missing capabilities fail with domain-specific unavailable codes instead of producing an authoritative empty list. `host/commands-changed` is the command-catalog invalidation frame: clients refetch `command.list` instead of diffing. + +`session.prompt` parses canonical session mentions from normalized text blocks and asks `ctx.sessionReferences` to prepare every referenced snapshot before enqueue. Parsing, cancellation, validation, reads, and budget enforcement are one admission transaction: failure enqueues no message, while success passes the returned display content and prompt-prefix contexts together to the agent. ## Carrier layer (`/client` + root) @@ -22,11 +24,11 @@ The `command.*` and `skill.*` domains expose the host command registry and skill ## Model Experience -None, as the package defines the client↔host wire contract and carriers; nothing here reaches a model request. +Indirectly, through `@deepseek-ai/dsh-session-reference`, which prepares canonical session mentions before `session.prompt` enqueues the message. #### KV Cache effect -None; this package neither assembles nor sends a provider request. +Candidate RPCs add no tokens. A successful referenced prompt adds only the context prepared by `ctx.sessionReferences`; failure leaves target history unchanged. ## Known Limitations and Deferred Work diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cc95a7512f..4bf9b7151a 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,7 +14,9 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*`、`skill.*` 与 `reference.*` 领域向客户端暴露宿主的命令、skill(技能)和引用功能。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 与 `reference.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`reference.files` 把可取消的路径发现委托给 `ctx.fileReferences`;`reference.sessions` 把候选排序和规范提及标记的创建委托给 `ctx.sessionReferences`。缺少功能时会以对应领域的 unavailable 错误码失败,而不是产生一个看似权威的空列表。`host/commands-changed` 是命令目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 + +`session.prompt` 从规范化文本块中解析规范会话提及标记,并要求 `ctx.sessionReferences` 在消息入队前准备每个被引用的快照。解析、取消、校验、读取和预算约束共同构成一个准入事务:失败时不会有消息入队;成功时则把返回的显示内容与提示词前缀上下文一起传给 agent。 ## 载体层(`/client` + 根路径) @@ -22,11 +24,11 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 模型体验 -无。该包定义客户端与宿主间的协议契约和载体,其中没有任何内容会进入模型请求。 +间接影响模型体验:`@deepseek-ai/dsh-session-reference` 会在 `session.prompt` 将消息入队前准备规范会话提及标记。 #### KV 缓存影响 -无;该包既不组装也不发送提供方请求。 +候选 RPC 不会增加 token。引用提示词成功后,只会增加 `ctx.sessionReferences` 准备的上下文;失败则不会改变目标历史。 ## 已知限制与延期工作 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 0c7107a1f9..1a0f2f04ed 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -43,9 +43,11 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f81f5c9be8..fa61a3fa6a 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -10,7 +10,13 @@ import type { Context } from 'cordis' import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-file-reference' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import { + formatSessionReferenceMention, + parseSessionReferenceText, + type SessionReferenceInput, +} from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { @@ -79,6 +85,21 @@ function err(request: RpcRequest, error: RpcError): RpcResponse { return { rpcId: request.rpcId, result: { ok: false, error } } } +/** Normalize every text block while preserving non-text blocks verbatim. */ +function parseReferencedContent(content: readonly ContentBlock[]): { + content: ContentBlock[] + references: SessionReferenceInput[] +} { + const references: SessionReferenceInput[] = [] + const normalized = content.map((block): ContentBlock => { + if (block.type !== 'text') return block + const parsed = parseSessionReferenceText(block.text) + references.push(...parsed.references) + return { ...block, text: parsed.text } + }) + return { content: normalized, references } +} + /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */ class FrameQueue { private buffer: F[] = [] @@ -645,16 +666,61 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { events: entries, hasMore: page.hasMore }) }, - async prompt(request) { + async prompt(request, signal) { const { sessionId, mode, content } = request.payload const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) const agent = found.agent + let parsed: ReturnType + try { + parsed = parseReferencedContent(content) + } catch (error: unknown) { + return err(request, { + code: 'reference-invalid', + message: 'invalid session reference', + details: { reason: String(error) }, + }) + } + let acceptedContent = parsed.content + let contexts: NonNullable[1]>['contexts'] = [] + if (parsed.references.length > 0) { + const sessionReferences = ctx.get('sessionReferences') + if (sessionReferences === undefined) { + return err(request, { + code: 'reference-unavailable', + message: 'session reference capability unavailable', + details: { kind: 'session' }, + }) + } + try { + const prepared = await sessionReferences.prepare( + agent, + acceptedContent, + parsed.references, + signal, + ) + acceptedContent = prepared.content + contexts = prepared.contexts + } catch (error: unknown) { + if (signal?.aborted === true) { + return err(request, { + code: 'cancelled', + message: 'session reference preparation was aborted', + details: {}, + }) + } + return err(request, { + code: 'reference-failed', + message: 'session reference preparation failed', + details: { reason: String(error) }, + }) + } + } // 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.followup(content, { source }) + if (mode === 'steer') agent.steer(acceptedContent, { source, contexts }) + else agent.followup(acceptedContent, { source, contexts }) } catch (error: unknown) { // 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) } }) @@ -885,6 +951,85 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + references: { + async files(request, signal) { + const { sessionId, query } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const fileReferences = ctx.get('fileReferences') + if (fileReferences === undefined) { + return err(request, { + code: 'reference-unavailable', + message: 'file reference capability unavailable', + details: { kind: 'file' }, + }) + } + const effectiveSignal = signal ?? new AbortController().signal + try { + return ok(request, { + items: await fileReferences.list(found.agent, query, effectiveSignal), + }) + } catch (error: unknown) { + if (effectiveSignal.aborted) { + return err(request, { + code: 'cancelled', + message: 'file reference listing was aborted', + details: {}, + }) + } + return err(request, { + code: 'reference-failed', + message: 'file reference listing failed', + details: { reason: String(error) }, + }) + } + }, + + async sessions(request, signal) { + const { sessionId, query } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const sessionReferences = ctx.get('sessionReferences') + if (sessionReferences === undefined) { + return err(request, { + code: 'reference-unavailable', + message: 'session reference capability unavailable', + details: { kind: 'session' }, + }) + } + try { + const candidates = await sessionReferences.listCandidates( + found.agent, + query, + undefined, + signal, + ) + return ok(request, { + items: candidates.map(candidate => ({ + ...candidate, + mention: formatSessionReferenceMention({ + sessionId: candidate.sessionId, + label: candidate.label, + }), + })), + }) + } catch (error: unknown) { + if (signal?.aborted === true) { + return err(request, { + code: 'cancelled', + message: 'session reference listing was aborted', + details: {}, + }) + } + return err(request, { + code: 'reference-failed', + message: 'session reference listing failed', + details: { reason: String(error) }, + }) + } + }, + }, + events: { mux(_request, signal) { const queue = new FrameQueue>() diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 537b2744ef..b4ff02a910 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -9,6 +9,7 @@ import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' import type { SkillsApi } from './skills.ts' +import type { ReferencesApi } from './references.ts' import type { EventsApi } from './events.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' @@ -19,6 +20,7 @@ export interface ApiProxy { workspace: WorkspaceApi commands: CommandsApi skills: SkillsApi + references: ReferencesApi events: EventsApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise @@ -30,6 +32,7 @@ export type { HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' +export type { FileReferenceItem, ReferencesApi, SessionReferenceItem } from './references.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' diff --git a/packages/host/apiproxy/src/api/references.schema.ts b/packages/host/apiproxy/src/api/references.schema.ts new file mode 100644 index 0000000000..59bee90e09 --- /dev/null +++ b/packages/host/apiproxy/src/api/references.schema.ts @@ -0,0 +1,42 @@ +/** + * Reference-domain wire schemas. + * + * @module @deepseek-ai/dsh-host-apiproxy/api/references.schema + */ + +import { z } from 'zod' +import type { RequestPayload, ResponseValue } from './rpc-map.ts' +import type { Wire } from './rpc.schema.ts' +import { sessionIdSchema } from './sessions.schema.ts' + +const referenceRequestSchema = z.object({ + sessionId: sessionIdSchema, + query: z.string(), +}) + +/** reference.files request payload. */ +export const referenceFilesRequestSchema = referenceRequestSchema satisfies + z.ZodType>> + +/** reference.files response value. */ +export const referenceFilesValueSchema = z.object({ + items: z.array(z.object({ + path: z.string(), + kind: z.union([z.literal('file'), z.literal('directory')]), + })), +}) satisfies z.ZodType>> + +/** reference.sessions request payload. */ +export const referenceSessionsRequestSchema = referenceRequestSchema satisfies + z.ZodType>> + +/** reference.sessions response value. */ +export const referenceSessionsValueSchema = z.object({ + items: z.array(z.object({ + sessionId: sessionIdSchema, + label: z.string(), + cwd: z.string().optional(), + createdAt: z.number(), + mention: z.string(), + })), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/references.ts b/packages/host/apiproxy/src/api/references.ts new file mode 100644 index 0000000000..13471f8d46 --- /dev/null +++ b/packages/host/apiproxy/src/api/references.ts @@ -0,0 +1,39 @@ +/** + * Reference autocomplete domain contract. + * + * @module @deepseek-ai/dsh-host-apiproxy/api/references + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RpcRequest, RpcResponse } from './rpc.ts' + +/** One file or directory candidate inside the target session workspace. */ +export interface FileReferenceItem { + path: string + kind: 'file' | 'directory' +} + +/** One metadata-only cross-session reference candidate. */ +export interface SessionReferenceItem { + sessionId: SessionId + label: string + cwd?: string + createdAt: number + /** Canonical opaque mention serialized into the prompt draft. */ + mention: string +} + +/** Host-backed file and session reference discovery. */ +export interface ReferencesApi { + /** List path candidates using the target agent's workspace boundary. */ + files( + request: RpcRequest<{ sessionId: SessionId; query: string }>, + signal?: AbortSignal, + ): Promise> + + /** List cross-session candidates excluding the target session itself. */ + sessions( + request: RpcRequest<{ sessionId: SessionId; query: string }>, + signal?: AbortSignal, + ): Promise> +} diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index abe992584c..5b41c35a19 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -9,6 +9,7 @@ import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' import type { SkillsApi } from './skills.ts' +import type { ReferencesApi } from './references.ts' import type { RpcResponse } from './rpc.ts' /** @@ -30,6 +31,8 @@ export interface RpcMethodMap { 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] + 'reference.files': ReferencesApi['files'] + 'reference.sessions': ReferencesApi['sessions'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index d83ae2ce98..653c699c8f 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -42,6 +42,9 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), + z.object({ code: z.literal('reference-unavailable'), message: z.string(), details: z.object({ kind: z.union([z.literal('file'), z.literal('session')]) }) }), + z.object({ code: z.literal('reference-invalid'), message: z.string(), details: z.object({ reason: z.string() }) }), + z.object({ code: z.literal('reference-failed'), 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 ad06c42fbe..7acd0e0bed 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -39,6 +39,9 @@ export interface RpcErrorDetailsMap { 'workspace-name-conflict': { name: string } 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } 'agent-busy': { reason: string } + 'reference-unavailable': { kind: 'file' | 'session' } + 'reference-invalid': { reason: string } + 'reference-failed': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 2552b5d5a3..2b3eb49fec 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -81,8 +81,15 @@ 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 a message. Canonical session mentions are normalized and their + * snapshots are prepared atomically before enqueue. mode maps 1:1 — + * queue→followup, steer→steer. + */ + prompt( + request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>, + signal?: AbortSignal, + ): Promise> /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0424ba7a4f..998480b806 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -29,6 +29,10 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' +import { + referenceFilesValueSchema, + referenceSessionsValueSchema, +} from '../api/references.schema.ts' /** * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary @@ -69,6 +73,10 @@ export interface IApiClient { skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } + references: { + files(payload: RequestPayload<'reference.files'>, signal?: AbortSignal): Promise>> + sessions(payload: RequestPayload<'reference.sessions'>, signal?: AbortSignal): Promise>> + } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> host(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -95,6 +103,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), } + readonly references: IApiClient['references'] = { + files: (payload, signal) => this.callUnary('reference.files', payload, signal), + sessions: (payload, signal) => this.callUnary('reference.sessions', payload, signal), + } + readonly events: IApiClient['events'] = { mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen), host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index b79980d63e..b205cce054 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -30,6 +30,10 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' +import { + referenceFilesRequestSchema, + referenceSessionsRequestSchema, +} from '../api/references.schema.ts' /** * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a @@ -38,7 +42,7 @@ import { skillListRequestSchema } from '../api/skills.schema.ts' * Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation * documented on Wire); the dispatch point carries the one Wire→exact cast. * Every invoke receives the carrier Request's signal; methods whose contract - * declares a signal parameter (command.execute) forward it, the rest ignore it. + * declares a signal parameter forwards it, the rest ignore it. */ type UnaryRoutes = { [K in keyof RpcMethodMap]: { @@ -51,7 +55,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) }, '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.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r, signal) => api.sessions.prompt(r, signal) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, @@ -61,6 +65,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, + 'reference.files': { schema: referenceFilesRequestSchema, invoke: (api, r, signal) => api.references.files(r, signal) }, + 'reference.sessions': { schema: referenceSessionsRequestSchema, invoke: (api, r, signal) => api.references.sessions(r, signal) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 8a63c3de32..abb2bb0ab6 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -58,6 +58,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly host: ApiProxy['host'] readonly commands: ApiProxy['commands'] readonly skills: ApiProxy['skills'] + readonly references: ApiProxy['references'] readonly events: ApiProxy['events'] readonly respond: ApiProxy['respond'] @@ -75,6 +76,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.host = api.host this.commands = api.commands this.skills = api.skills + this.references = api.references this.events = api.events // createApiProxy returns closures (no `this` capture); bind only satisfies // the unbound-method lint without changing behavior. diff --git a/packages/host/apiproxy/tests/api-proxy-references.spec.ts b/packages/host/apiproxy/tests/api-proxy-references.spec.ts new file mode 100644 index 0000000000..972e9c6938 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-references.spec.ts @@ -0,0 +1,214 @@ +/** + * Reference RPC coverage over the real ApiProxy: addressed Host discovery, + * canonical session mentions, atomic snapshot preparation before enqueue, + * and error/cancellation behavior. + */ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +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' } +let nextRpc = 1 + +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`reference-${String(nextRpc++)}`), payload } +} + +function expectOk(response: RpcResponse): T { + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + return response.result.value +} + +function expectErr(response: RpcResponse): { code: string; message: string } { + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + return response.result.error +} + +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + ctx.provide('workspace', { list: () => [] } as never) + return ctx +} + +function stubAgent(ctx: Context) { + const session = ctx.sessions.create(undefined, { meta: { cwd: '/project' } }) + const followup = vi.fn() + const steer = vi.fn() + const agent = { + id: session.id, + session, + status: 'idle', + ctx, + followup, + steer, + cancel: vi.fn(), + } as unknown as Agent & { + followup: typeof followup + steer: typeof steer + } + ctx.agents.register(agent) + return agent +} + +describe('reference discovery', () => { + it('addresses the target agent and returns file candidates unchanged', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const list = vi.fn(() => Promise.resolve([ + { path: 'src', kind: 'directory' as const }, + { path: 'src/index.ts', kind: 'file' as const }, + ])) + ctx.provide('fileReferences', { list } as never) + const api = createApiProxy(ctx, DEFAULTS) + const signal = new AbortController().signal + const value = expectOk(await api.references.files( + request({ sessionId: agent.id, query: 'sr' }), + signal, + )) + expect(value.items).toEqual([ + { path: 'src', kind: 'directory' }, + { path: 'src/index.ts', kind: 'file' }, + ]) + expect(list).toHaveBeenCalledWith(agent, 'sr', signal) + }) + + it('formats metadata candidates as opaque canonical mentions', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const source = 'source-session' as SessionId + const listCandidates = vi.fn(() => Promise.resolve([{ + sessionId: source, + label: 'Research]', + cwd: '/project', + createdAt: 42, + }])) + ctx.provide('sessionReferences', { listCandidates } as never) + const api = createApiProxy(ctx, DEFAULTS) + const value = expectOk(await api.references.sessions( + request({ sessionId: agent.id, query: 'res' }), + new AbortController().signal, + )) + expect(value.items).toEqual([{ + sessionId: source, + label: 'Research]', + cwd: '/project', + createdAt: 42, + mention: formatSessionReferenceMention({ sessionId: source, label: 'Research]' }), + }]) + expect(listCandidates).toHaveBeenCalledWith(agent, 'res', undefined, expect.any(AbortSignal)) + }) + + it('fails explicitly when a reference capability is not composed', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const api = createApiProxy(ctx, DEFAULTS) + expect(expectErr(await api.references.files( + request({ sessionId: agent.id, query: '' }), + )).code).toBe('reference-unavailable') + expect(expectErr(await api.references.sessions( + request({ sessionId: agent.id, query: '' }), + )).code).toBe('reference-unavailable') + }) +}) + +describe('referenced prompt preparation', () => { + it('normalizes the visible mention and waits for all context preparation before enqueue', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const source = 'source-session' as SessionId + const mention = formatSessionReferenceMention({ sessionId: source, label: 'Research' }) + let finish!: () => void + const context = { + source: { kind: 'plugin' as const, plugin: 'session-reference' }, + content: [{ type: 'text' as const, text: 'snapshot' }], + placement: 'prompt-prefix' as const, + meta: { + kind: 'session-reference', + version: 1, + references: [{ sessionId: source, label: 'Research' }], + }, + } + const prepare = vi.fn(() => new Promise<{ + content: { type: 'text'; text: string }[] + contexts: typeof context[] + }>((resolve) => { + finish = () => { + resolve({ + content: [{ type: 'text', text: 'compare @Research now' }], + contexts: [context], + }) + } + })) + ctx.provide('sessionReferences', { prepare } as never) + const api = createApiProxy(ctx, DEFAULTS) + const signal = new AbortController().signal + const pending = api.sessions.prompt(request({ + sessionId: agent.id, + content: [{ type: 'text' as const, text: `compare ${mention} now` }], + mode: 'queue' as const, + }), signal) + await Promise.resolve() + expect(agent.followup).not.toHaveBeenCalled() + expect(prepare).toHaveBeenCalledWith( + agent, + [{ type: 'text', text: 'compare @Research now' }], + [{ sessionId: source, label: 'Research' }], + signal, + ) + finish() + expect(expectOk(await pending)).toEqual({ accepted: true }) + expect(agent.followup).toHaveBeenCalledTimes(1) + expect(agent.followup.mock.calls[0]?.[0]).toEqual([ + { type: 'text', text: 'compare @Research now' }, + ]) + expect(agent.followup.mock.calls[0]?.[1]?.source?.kind).toBe('user') + expect(agent.followup.mock.calls[0]?.[1]?.contexts).toEqual([context]) + }) + + it('rejects malformed mentions and preparation failures without enqueueing any prompt', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const prepare = vi.fn(() => Promise.reject(new Error('snapshot unavailable'))) + ctx.provide('sessionReferences', { prepare } as never) + const api = createApiProxy(ctx, DEFAULTS) + + const malformed = await api.sessions.prompt(request({ + sessionId: agent.id, + content: [{ type: 'text' as const, text: '@[bad](dsh-session:not-canonical)' }], + mode: 'queue' as const, + })) + expect(expectErr(malformed).code).toBe('reference-invalid') + expect(prepare).not.toHaveBeenCalled() + expect(agent.followup).not.toHaveBeenCalled() + + const mention = formatSessionReferenceMention({ + sessionId: 'source-session' as SessionId, + label: 'Research', + }) + const failed = await api.sessions.prompt(request({ + sessionId: agent.id, + content: [{ type: 'text' as const, text: mention }], + mode: 'queue' as const, + })) + expect(expectErr(failed).code).toBe('reference-failed') + expect(agent.followup).not.toHaveBeenCalled() + expect(agent.steer).not.toHaveBeenCalled() + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index a9a5eac9ba..a4b5ce4c3b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -22,6 +22,7 @@ function scriptedApi(overrides: { host?: Partial commands?: Partial skills?: Partial + references?: Partial events?: Partial respond?: ApiProxy['respond'] } = {}): ApiProxy { @@ -48,6 +49,11 @@ function scriptedApi(overrides: { ...overrides.commands, }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, + references: { + files: r => ok(r, { items: [] }), + sessions: r => ok(r, { items: [] }), + ...overrides.references, + }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), } @@ -89,6 +95,39 @@ describe('unary round trip', () => { expect(appended.result.ok).toBe(true) }) + it('routes file and session reference candidates through their wire schemas', async () => { + const c = client(scriptedApi({ + references: { + files: r => ok(r, { items: [{ path: 'src/index.ts', kind: 'file' as const }] }), + sessions: r => ok(r, { + items: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 42, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], + }), + }, + })) + await expect(c.references.files({ sessionId: sid('target'), query: 'src' })).resolves.toMatchObject({ + result: { ok: true, value: { items: [{ path: 'src/index.ts', kind: 'file' }] } }, + }) + await expect(c.references.sessions({ sessionId: sid('target'), query: 'res' })).resolves.toMatchObject({ + result: { + ok: true, + value: { + items: [{ + sessionId: 'source', + label: 'Research', + cwd: '/project', + createdAt: 42, + }], + }, + }, + }) + }) + it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ sessions: { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index e8d65d2a62..187944f8d7 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -89,6 +89,14 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } } }, }, + references: { + async files(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } + }, + async sessions(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } + }, + }, events: { mux: (_request, signal) => stream(muxFrames, signal), host: (_request, signal) => stream(hostFrames, signal), diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index f5aabb1cf8..97943e335a 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/agent" }, + { + "path": "../../context/file-reference" + }, { "path": "../../core/session" }, @@ -32,6 +35,9 @@ { "path": "../../session-persistence/session-persistence" }, + { + "path": "../../context/session-reference" + }, { "path": "../../session-title/session-title" }, diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 3242f5a2a3..afbda036b1 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -34,6 +34,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-file-reference-local": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -66,6 +67,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index de0ef02f7e..1e29c678ef 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -169,22 +169,21 @@ import { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, + FILE_REFERENCE_PROMPT, formatFileMention, WorkspaceFileSearch, -} from './file-autocomplete.ts' +} from '@deepseek-ai/dsh-file-reference-local' export { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, -} from './file-autocomplete.ts' + FILE_REFERENCE_PROMPT, +} from '@deepseek-ai/dsh-file-reference-local' export const name = 'ui-tui' export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] -/** Model guidance for path-only file references selected through the TUI. */ -export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' - /** Interaction and presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { /** Render model reasoning blocks. */ diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c7af95061a..8bae946199 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -26,7 +26,7 @@ import { type TuiOverlaySession, type TuiRuntime, } from '../src/index.ts' -import { WorkspaceFileSearch } from '../src/file-autocomplete.ts' +import { WorkspaceFileSearch } from '@deepseek-ai/dsh-file-reference-local' import { appendAssistant, appendUser, diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 3560d6bc9d..5d8dd39387 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../context/file-reference-local" + }, { "path": "../../core/session" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..9b77726d36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,9 @@ importers: '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../packages/client/ui-question + '@deepseek-ai/dsh-client-ui-reference': + specifier: workspace:^ + version: link:../../packages/client/ui-reference '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../packages/client/ui-settings @@ -164,9 +167,6 @@ importers: '@deepseek-ai/dsh-client-ui-slash': specifier: workspace:^ version: link:../../packages/client/ui-slash - '@deepseek-ai/dsh-client-ui-subagent': - specifier: workspace:^ - version: link:../../packages/client/ui-subagent '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../packages/client/ui-theme @@ -185,6 +185,12 @@ importers: '@deepseek-ai/dsh-compact-basic': specifier: workspace:^ version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../packages/context/file-reference + '@deepseek-ai/dsh-file-reference-local': + specifier: workspace:^ + version: link:../../packages/context/file-reference-local '@deepseek-ai/dsh-frontend': specifier: workspace:^ version: link:../web @@ -221,6 +227,12 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../packages/session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../packages/session-query/session-query-sqlite + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../packages/context/session-reference '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title @@ -1072,6 +1084,27 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-reference: + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-slash': + specifier: workspace:^ + version: link:../ui-slash + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-settings: dependencies: clsx: @@ -1197,6 +1230,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1222,24 +1258,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/client/ui-subagent: - devDependencies: - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-slash': - specifier: workspace:^ - version: link:../ui-slash - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/client/ui-theme: dependencies: clsx: @@ -1514,6 +1532,43 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/context/file-reference: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/context/file-reference-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../file-reference + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@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: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/context/session-reference: dependencies: schemastery: @@ -2550,6 +2605,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../ui/commands + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2559,6 +2617,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session-title/session-title @@ -4296,6 +4357,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../commands + '@deepseek-ai/dsh-file-reference-local': + specifier: workspace:^ + version: link:../../context/file-reference-local '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 693f0aab60..6a663f865e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -221,6 +221,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md', DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md', DomainSpec: 'domain declaration contract is owned by packages/storage/storage-domain/README.md', + FileReferenceCandidate: 'service-local discovery result is owned by packages/context/file-reference/README.md', StorageBackend: 'backend contract is owned by packages/storage/storage/src/backend.ts', StorageForms: 'merge-extensible form map is owned by packages/storage/storage/src/index.ts', InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 990acd60c5..b7203bdffe 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -169,13 +169,22 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['session-reference', 'tool-session-query'], note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering.', }, + { + key: 'fileReferences', + pkg: 'file-reference', + title: 'Workspace file-reference discovery', + mode: 'seam', + implementations: ['file-reference-local'], + consumers: ['apiproxy'], + note: 'The local provider owns one invalidated path index per agent; Host RPC projects its cancellable path candidates to browser reference sources.', + }, { key: 'sessionReferences', pkg: 'session-reference', title: 'Cross-session snapshot preparation', mode: 'core', - consumers: ['tui'], - note: 'Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax.', + consumers: ['apiproxy', 'tui'], + note: 'Projects bounded current-surface conversation snapshots into durable untrusted message context; Host and TUI adapters own mention syntax.', }, { key: 'sessionTitle', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0aa5ae6c34..9ae2d223ed 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -57,6 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, + 'packages/client/ui-reference': { kind: 'indirect', reason: 'The browser source serializes references whose model rendering is owned by file-reference-local and session-reference.' }, 'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, @@ -71,7 +72,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, - 'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' }, + 'packages/context/file-reference': { kind: 'indirect', reason: 'The interface and grammar delegate model guidance to concrete file-reference providers.' }, + 'packages/host/apiproxy': { kind: 'indirect', reason: 'The gateway delegates prepared cross-session model context to dsh-session-reference.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index fcfe6f6e90..49da491450 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -41,6 +41,8 @@ "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], "@deepseek-ai/dsh-session/types": ["./packages/core/session/src/types.ts"], "@deepseek-ai/dsh-session/surface": ["./packages/core/session/src/surface.ts"], + "@deepseek-ai/dsh-session/display": ["./packages/core/session/src/display.ts"], + "@deepseek-ai/dsh-file-reference/grammar": ["./packages/context/file-reference/src/grammar.ts"], "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], @@ -114,7 +116,7 @@ "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], - "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], + "@deepseek-ai/dsh-client-ui-reference": ["./packages/client/ui-reference/src"], "@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], "@deepseek-ai/dsh-client-ui-workspace": ["./packages/client/ui-workspace/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 0da6e76918..33fd294cc8 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -38,7 +38,7 @@ { "path": "./packages/client/ui-slash" }, { "path": "./packages/client/ui-command" }, { "path": "./packages/client/ui-skill" }, - { "path": "./packages/client/ui-subagent" }, + { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 2c368e53ed..7b0716af37 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -75,6 +75,8 @@ { "path": "./packages/goal/goal-session" }, { "path": "./packages/goal/command-goal" }, { "path": "./packages/context/time-context" }, + { "path": "./packages/context/file-reference" }, + { "path": "./packages/context/file-reference-local" }, { "path": "./packages/context/session-reference" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/vitest.config.ts b/vitest.config.ts index 33228e21ec..adb3f5a855 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -128,7 +128,7 @@ export default defineConfig({ 'packages/client/ui-command/src/index.ts', 'packages/client/ui-skill/src/index.ts', 'packages/client/ui-slash/src/index.ts', - 'packages/client/ui-subagent/src/index.ts', + 'packages/client/ui-reference/src/index.ts', 'packages/client/ui-command/src/client/popup.ts', 'packages/client/ui-command/src/client/directory.ts', 'packages/client/ui-command/src/client/service.ts', From 12f69144a426386c59aeeae2091979d0fdce1a73 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 28 Jul 2026 15:16:36 +0800 Subject: [PATCH 002/180] fix(web): harden session reference submission --- ...-web-file-and-session-references.i18n.yaml | 4 +- ...6-07-27-web-file-and-session-references.md | 4 +- ...7-27-web-file-and-session-references.zh.md | 4 +- .../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.tsx | 21 ++++++++-- .../tests/chat-branch-tails.spec.tsx | 16 ++++++++ packages/host/apiproxy/src/api-proxy.ts | 7 ++++ .../tests/api-proxy-references.spec.ts | 39 +++++++++++++++++++ 10 files changed, 90 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml index 0c8290a540..c8a03e6c06 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages 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-web-file-and-session-references.md -2026-07-27-web-file-and-session-references.md: 71648a6ddbcffc7e700db1b0ce2135bf157e2bab -2026-07-27-web-file-and-session-references.zh.md: 05c0896700ae55c64b94c0b37a00a38127c97f1f +2026-07-27-web-file-and-session-references.md: 98aad4b40a47a27cd66a3bb5a040cf52dcbfbbd5 +2026-07-27-web-file-and-session-references.zh.md: f0ba076a013aa17c26eec95dcf8c5eb66c7b3829 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md index 71648a6ddb..98aad4b40a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -16,7 +16,7 @@ The file capability follows the three-package seam: `@deepseek-ai/dsh-file-refer A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. `session.prompt` parses those mentions and calls `ctx.sessionReferences.prepare()` before enqueue, then passes the prepared content and contexts in one agent operation. Invalid mentions, cancellation, missing capability, source-read failure, and budget failure enqueue nothing. -The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or RPC failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders each metadata-confirmed session label as a reference chip even when following text is adjacent, plus a compact session-source summary instead of the snapshot JSON baked into model content. +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or RPC failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders adjacent metadata-confirmed session references as separate reference chips and preserves that projection when following text is adjacent without whitespace, plus a compact session-source summary instead of the snapshot JSON baked into model content. ## Reference transaction @@ -42,7 +42,7 @@ File lookup is advisory and cancellable; selection itself performs no read. Sess ## Verification -Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-text reference projection, codec round-trip, Host wire validation, all-or-nothing prompt preparation, and draft retention across serialization and RPC failures. The keyless assembled Web snapshot renders the available reference sections, selects a directory and file, then selects a session reference through the real client composition. +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, Host wire validation, all-or-nothing prompt preparation, and draft retention across serialization and RPC failures. The keyless assembled Web snapshot renders the available reference sections, selects a directory and file, then selects a session reference through the real client composition. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md index 05c0896700..f0ba076a01 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -16,7 +16,7 @@ Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` 提及标记。`session.prompt` 会解析这些提及标记,并在入队前调用 `ctx.sessionReferences.prepare()`,随后以一次 agent 操作传入准备后的内容和上下文。无效提及标记、取消、功能缺失、读取源会话失败和预算失败都不会让消息入队。 -输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 RPC 失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把元数据确认的每个会话标签渲染为引用 chip,即使后续文本与标签直接相邻也如此,并显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 RPC 失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把相邻且经元数据确认的会话引用分别渲染为独立的引用 chip,即使后续文本无空白紧邻,也会保持该投影;同时显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 ## 引用事务 @@ -42,7 +42,7 @@ type @ → parallel file/session RPCs → pick path text or canonical session ch ## 验证 -包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻文本条件下的引用投影、codec 无损往返、宿主协议校验、全有或全无的提示词准备,以及在序列化和 RPC 失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择目录、文件和会话引用。 +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、宿主协议校验、全有或全无的提示词准备,以及在序列化和 RPC 失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择目录、文件和会话引用。 ## 后果 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a2c820b67a..b09d64e2c2 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: e57c46ef7153678705770b761ed78912feaa7a04 -README.zh.md: 985886f6963d2cd517c71c211d4979623276ca74 +README.md: 1324ac27eb0e385fee7bcfa78e604752cd0aa108 +README.zh.md: 35a7d65b3e7d9d057c0eed57e5eaaf7f4b09892a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e57c46ef71..1324ac27eb 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ 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'` and `'conversation.input.model'`, plus list slots for overlay, dock, left, and right input extensions. InputBar renders the model seat immediately before its pending indicator and send/stop button. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. -Ordinary submission is a transaction between the input machine and its default sink. The composer retains its draft and atomic reference chips while serialization or `session.prompt` is pending, clears them only after Host acceptance, and restores the editable phase unchanged after rejection. Replay distinguishes sourced session-reference context from direct user messages, associates adjacent pairs, projects confirmed labels as reference chips even when following prompt text is adjacent, and adds a compact `引用会话` source summary below the direct user text instead of exposing the prepared snapshot JSON. +Ordinary submission is a transaction between the input machine and its default sink. The composer retains its draft and atomic reference chips while serialization or `session.prompt` is pending, clears them only after Host acceptance, and restores the editable phase unchanged after rejection. Replay distinguishes sourced session-reference context from direct user messages, associates adjacent pairs, projects adjacent confirmed session references as separate reference chips, preserves that projection when following prompt text is adjacent without whitespace, and adds a compact `引用会话` source summary below the direct user text instead of exposing the prepared snapshot JSON. `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). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 985886f696..35a7d65b3e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -18,7 +18,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 输入栏为 `'conversation.input.plan'` 和 `'conversation.input.model'` 声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。InputBar 将模型 seat 渲染在 pending 指示器与发送/停止按钮之前。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 -普通提交是输入状态机与默认 sink 之间的一项事务。在序列化或 `session.prompt` 等待完成期间,输入框会保留草稿和原子引用 chip;只有宿主接受后才会将它们清除,拒绝后则原样恢复可编辑阶段。回放会区分带来源信息的会话引用上下文与直接用户消息,并关联相邻的消息对;即使后续提示词文本与标签直接相邻,也会把已确认的标签投影为引用 chip,并在直接用户文本下方添加精简的 `引用会话` 来源摘要,而不会暴露准备好的快照 JSON。 +普通提交是输入状态机与默认 sink 之间的一项事务。在序列化或 `session.prompt` 等待完成期间,输入框会保留草稿和原子引用 chip;只有宿主接受后才会将它们清除,拒绝后则原样恢复可编辑阶段。回放会区分带来源信息的会话引用上下文与直接用户消息,并关联相邻的消息对;相邻的已确认会话引用会分别投影为独立的引用 chip,即使后续提示词文本无空白紧邻,也会保持该投影;同时在直接用户文本下方添加精简的 `引用会话` 来源摘要,而不会暴露准备好的快照 JSON。 `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/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 1c9b8eead3..f598682614 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -79,9 +79,9 @@ function projectUserText(text: string, sessionLabels: readonly string[] = []): R .filter(label => label.length > 0) .sort((left, right) => right.length - left.length) .map(label => label.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')) - const sessionPattern = exactSessions.length === 0 ? '' : `@(?:${exactSessions.join('|')})|` + const sessionPattern = exactSessions.length === 0 ? '(?!)' : `@(?:${exactSessions.join('|')})` const re = new RegExp( - `([^<]+)|(^|\\s)(${sessionPattern}[/@][\\w-]+(?=\\s|$))`, + `([^<]+)|(^|\\s)(?:((?:${sessionPattern})+)|([/@][\\w-]+(?=\\s|$)))`, 'gu', ) const parts: ReactNode[] = [] @@ -90,8 +90,23 @@ function projectUserText(text: string, sessionLabels: readonly string[] = []): R while ((m = re.exec(text)) !== null) { const legacy = m[1] !== undefined const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0) - const label = legacy ? `/${m[1]}` : m[3] ?? '' if (tokenStart > cursor) parts.push() + const sessionRun = m[3] + if (sessionRun !== undefined) { + const sessionRe = new RegExp(sessionPattern, 'gu') + let session: RegExpExecArray | null + while ((session = sessionRe.exec(sessionRun)) !== null) { + const start = tokenStart + session.index + parts.push( + + {session[0]} + , + ) + } + cursor = tokenStart + sessionRun.length + continue + } + const label = legacy ? `/${m[1]}` : m[4] ?? '' parts.push( {label} 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 76b47a9ab2..84999f48d0 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -50,6 +50,22 @@ describe('MessageItem arms', () => { expect(view.container.querySelector('[data-ref-chip="reference"]')?.textContent).toBe('@你好') }) + it('styles adjacent confirmed session labels as separate reference chips', () => { + const view = render( + , + ) + expect(view.container.textContent).toContain('@Research notes@Research继续') + expect([...view.container.querySelectorAll('[data-ref-chip="reference"]')] + .map(chip => chip.textContent)).toEqual(['@Research notes', '@Research']) + }) + it('user bubbles expose copy / branch / edit actions; copy writes the text', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 7dd735f910..5d042ec113 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -967,6 +967,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } } + if (signal?.aborted === true) { + return err(request, { + code: 'cancelled', + message: 'prompt submission was aborted', + details: {}, + }) + } // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { diff --git a/packages/host/apiproxy/tests/api-proxy-references.spec.ts b/packages/host/apiproxy/tests/api-proxy-references.spec.ts index b9ae0a4266..6a6ca3bf94 100644 --- a/packages/host/apiproxy/tests/api-proxy-references.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-references.spec.ts @@ -242,6 +242,45 @@ describe('referenced prompt preparation', () => { ) }) + it.each(['queue', 'steer'] as const)( + 'does not deliver a %s prompt when preparation resolves after cancellation', + async (mode) => { + const ctx = await harness() + const agent = stubAgent(ctx) + const controller = new AbortController() + const source = 'source-session' as SessionId + const mention = formatSessionReferenceMention({ sessionId: source, label: 'Research' }) + ctx.provide('sessionReferences', { + prepare: async () => { + controller.abort() + return { + content: [{ type: 'text' as const, text: '@Research' }], + additionalContext: { + source: { + kind: 'session-reference' as const, + version: 1 as const, + references: [{ sessionId: source, label: 'Research' }], + }, + content: [{ type: 'text' as const, text: 'snapshot' }], + }, + } + }, + } as never) + const api = createApiProxy(ctx, DEFAULTS) + + const response = await api.sessions.prompt(request({ + sessionId: agent.id, + content: [{ type: 'text' as const, text: mention }], + mode, + }), controller.signal) + + expect(expectErr(response).code).toBe('cancelled') + expect(agent.followup).not.toHaveBeenCalled() + expect(agent.steer).not.toHaveBeenCalled() + expect(agent.inject).not.toHaveBeenCalled() + }, + ) + it('rejects malformed mentions and preparation failures without enqueueing any prompt', async () => { const ctx = await harness() const agent = stubAgent(ctx) From e3d02f3c10f82c3559e2aceb60a6ddff2971237a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 10:43:35 +0800 Subject: [PATCH 003/180] fix(web): deduplicate reference submission paths --- .../src/client/chat/MessageItem.tsx | 46 +++++++++++-------- .../src/client/input/facade.ts | 25 +++------- packages/host/apiproxy/src/api-proxy.ts | 35 +++++++++----- 3 files changed, 58 insertions(+), 48 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 3c04422981..75e886f5ff 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -81,21 +81,38 @@ function projectUserText(text: string, sessionLabels: readonly string[] = []): R return <>{parts} } +function UserBubble({ + text, + rest, + sessionLabels, + steering, +}: { + text: string + rest: readonly unknown[] + sessionLabels: readonly string[] + steering?: boolean +}): ReactNode { + return ( +

+
+ {steering === true ? 插话 : null} + {projectUserText(text, sessionLabels)} + {rest.map((block, i) => )} +
+ {sessionLabels.length > 0 + ?
引用会话 · {sessionLabels.join(', ')}
+ : null} +
+ ) +} + export const MessageItem = memo(function MessageItem({ node, sessionLabels = [] }: MessageItemProps) { switch (node.kind) { case 'user': { const { text, rest } = contentText(node.content) return (
-
-
- {projectUserText(text, sessionLabels)} - {rest.map((block, i) => )} -
- {sessionLabels.length > 0 - ?
引用会话 · {sessionLabels.join(', ')}
- : null} -
+ -
-
- 插话 - {projectUserText(text, sessionLabels)} - {rest.map((block, i) => )} -
- {sessionLabels.length > 0 - ?
引用会话 · {sessionLabels.join(', ')}
- : null} -
+
) } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 85dc945a77..f984f9ba89 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -348,7 +348,7 @@ export class SessionInputShell implements SessionInput { private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: 'queue' | 'steer'): void { const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.settleDefault(attempt, this.deps.defaultSink(draft.trim(), mode, attempt.signal)) + this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), mode, attempt.signal)) return } const slash = this.deps.slash?.() @@ -368,7 +368,7 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + 1 } out += draft.slice(cursor) - this.settleDefault(attempt, this.deps.defaultSink(out.trim(), mode, attempt.signal)) + this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), mode, attempt.signal)) }, (error: unknown) => { controller.abort() @@ -384,7 +384,7 @@ export class SessionInputShell implements SessionInput { ) } - private settleDefault( + private settleSubmit( attempt: SubmitAttempt, pending: Promise, ): void { @@ -433,21 +433,10 @@ export class SessionInputShell implements SessionInput { /** The submit transaction: claim.submit against the session scope; ok maps from the outcome kind. */ private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void { - Promise.resolve() - .then(() => claim.submit(args, this.deps.actx)) - .then( - (outcome) => { - if (this.dead(attempt)) return - this.run(this.core.dispatch({ - type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome, - })) - }, - (error: unknown) => { - if (this.dead(attempt)) return - const message = error instanceof Error ? error.message : String(error) - this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) - }, - ) + this.settleSubmit( + attempt, + Promise.resolve().then(() => claim.submit(args, this.deps.actx)), + ) } /** Late-settlement guard: superseded attempts and disposed facades drop silently. */ diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8329fae188..ee521e74ce 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { - Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement, + Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement, PromptDecision, } from '@deepseek-ai/dsh-agent' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' @@ -458,6 +458,22 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie } } +async function appendPreparedPromptContext( + matchesPrompt: boolean, + additionalContext: UserMessage, + cleanup: () => void, + next: () => Promise, +): Promise { + if (!matchesPrompt) return next() + cleanup() + const decision = await next() + if (decision.kind !== 'allow') return decision + return { + ...decision, + additionalContexts: [...decision.additionalContexts ?? [], additionalContext], + } +} + /** * Deliver a prepared browser prompt while preserving session-reference * admission ownership under the context-injection contract. @@ -490,16 +506,13 @@ function deliverPrompt( detachSubmit() detachDiscard() } - detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _signal, next) => { - if (subject !== agent || submitted.id !== message.id) return next() - cleanup() - const decision = await next() - if (decision.kind !== 'allow') return decision - return { - ...decision, - additionalContexts: [...decision.additionalContexts ?? [], additionalContext], - } - }, { prepend: true }) + detachSubmit = ctx.on('agent/prompt-submit', (subject, submitted, _signal, next) => + appendPreparedPromptContext( + subject === agent && submitted.id === message.id, + additionalContext, + cleanup, + next, + ), { prepend: true }) detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent && messages.some(discarded => discarded.id === message.id)) cleanup() }) From 3a231b122e05e5418a8a9b48a6b8cb7a0b9b9705 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 15:47:18 +0800 Subject: [PATCH 004/180] test(web): cover ui-subagent host half --- packages/client/ui-subagent/tests/browser-plugin.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 648381a8ed..84c4453214 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -13,6 +13,7 @@ import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from '../src/client/SubagentReadOnlyComposer.tsx' import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' const sid = (id: string) => id as SessionId @@ -56,6 +57,10 @@ async function bench() { } describe('apply', () => { + it('exposes a no-op Host half for Loader discovery', () => { + expect(nodeApply).not.toThrow() + }) + it('declares the services it binds', () => { expect(inject).toEqual(['sessions', 'conversation', 'slots', 'locale']) }) From f248dc37732062cdb4c2f23b9b2c8f0c403a9a74 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 4 Aug 2026 11:38:42 +0800 Subject: [PATCH 005/180] docs: remove line numbers from subsystem catalog links --- docs/subsystems/approval.i18n.yaml | 4 +-- docs/subsystems/approval.md | 4 +-- docs/subsystems/approval.zh.md | 4 +-- docs/subsystems/bash.i18n.yaml | 4 +-- docs/subsystems/bash.md | 4 +-- docs/subsystems/bash.zh.md | 4 +-- docs/subsystems/client-modules.i18n.yaml | 4 +-- docs/subsystems/client-modules.md | 2 +- docs/subsystems/client-modules.zh.md | 2 +- docs/subsystems/code-runtime.i18n.yaml | 4 +-- docs/subsystems/code-runtime.md | 2 +- docs/subsystems/code-runtime.zh.md | 2 +- docs/subsystems/commands.i18n.yaml | 4 +-- docs/subsystems/commands.md | 4 +-- docs/subsystems/commands.zh.md | 4 +-- 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 | 32 ++++++++--------- docs/subsystems/core.zh.md | 32 ++++++++--------- docs/subsystems/credentials.i18n.yaml | 4 +-- docs/subsystems/credentials.md | 4 +-- docs/subsystems/credentials.zh.md | 4 +-- docs/subsystems/filesystem.i18n.yaml | 4 +-- docs/subsystems/filesystem.md | 8 ++--- docs/subsystems/filesystem.zh.md | 8 ++--- 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 | 2 +- docs/subsystems/http-server.zh.md | 2 +- docs/subsystems/invariants.i18n.yaml | 4 +-- docs/subsystems/invariants.md | 2 +- docs/subsystems/invariants.zh.md | 2 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +-- docs/subsystems/llm-streaming.md | 6 ++-- docs/subsystems/llm-streaming.zh.md | 6 ++-- docs/subsystems/permission.i18n.yaml | 4 +-- docs/subsystems/permission.md | 2 +- docs/subsystems/permission.zh.md | 2 +- 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 | 2 +- docs/subsystems/plan.zh.md | 2 +- docs/subsystems/pty.i18n.yaml | 4 +-- docs/subsystems/pty.md | 2 +- docs/subsystems/pty.zh.md | 2 +- docs/subsystems/sandbox.i18n.yaml | 4 +-- docs/subsystems/sandbox.md | 4 +-- docs/subsystems/sandbox.zh.md | 4 +-- docs/subsystems/session-projection.i18n.yaml | 4 +-- docs/subsystems/session-projection.md | 4 +-- docs/subsystems/session-projection.zh.md | 4 +-- docs/subsystems/session-query.i18n.yaml | 4 +-- docs/subsystems/session-query.md | 2 +- docs/subsystems/session-query.zh.md | 2 +- docs/subsystems/session-reference.i18n.yaml | 4 +-- docs/subsystems/session-reference.md | 2 +- docs/subsystems/session-reference.zh.md | 2 +- docs/subsystems/session-title.i18n.yaml | 4 +-- docs/subsystems/session-title.md | 2 +- docs/subsystems/session-title.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +-- docs/subsystems/session.md | 10 +++--- docs/subsystems/session.zh.md | 10 +++--- docs/subsystems/settings.i18n.yaml | 4 +-- docs/subsystems/settings.md | 6 ++-- docs/subsystems/settings.zh.md | 6 ++-- docs/subsystems/skills.i18n.yaml | 4 +-- docs/subsystems/skills.md | 4 +-- docs/subsystems/skills.zh.md | 4 +-- docs/subsystems/spill.i18n.yaml | 4 +-- docs/subsystems/spill.md | 2 +- docs/subsystems/spill.zh.md | 2 +- 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 | 10 +++--- docs/subsystems/subagent.zh.md | 10 +++--- docs/subsystems/subprocess.i18n.yaml | 4 +-- docs/subsystems/subprocess.md | 4 +-- docs/subsystems/subprocess.zh.md | 4 +-- docs/subsystems/system-prompt.i18n.yaml | 4 +-- docs/subsystems/system-prompt.md | 6 ++-- docs/subsystems/system-prompt.zh.md | 6 ++-- docs/subsystems/tasks.i18n.yaml | 4 +-- docs/subsystems/tasks.md | 2 +- docs/subsystems/tasks.zh.md | 2 +- docs/subsystems/telemetry.i18n.yaml | 4 +-- docs/subsystems/telemetry.md | 4 +-- docs/subsystems/telemetry.zh.md | 4 +-- docs/subsystems/token-meter.i18n.yaml | 4 +-- docs/subsystems/token-meter.md | 2 +- docs/subsystems/token-meter.zh.md | 2 +- docs/subsystems/tools.i18n.yaml | 4 +-- docs/subsystems/tools.md | 14 ++++---- docs/subsystems/tools.zh.md | 14 ++++---- docs/subsystems/typert.i18n.yaml | 4 +-- docs/subsystems/typert.md | 4 +-- docs/subsystems/typert.zh.md | 4 +-- docs/subsystems/user-interaction.i18n.yaml | 4 +-- docs/subsystems/user-interaction.md | 2 +- docs/subsystems/user-interaction.zh.md | 2 +- docs/subsystems/web.i18n.yaml | 4 +-- docs/subsystems/web.md | 2 +- docs/subsystems/web.zh.md | 2 +- docs/subsystems/workflow.i18n.yaml | 4 +-- docs/subsystems/workflow.md | 14 ++++---- docs/subsystems/workflow.zh.md | 14 ++++---- docs/subsystems/workspace.i18n.yaml | 4 +-- docs/subsystems/workspace.md | 4 +-- docs/subsystems/workspace.zh.md | 4 +-- .../typert/generator/src/cordis-catalog.ts | 10 ++++-- .../generator/tests/cordis-catalog.spec.ts | 34 +++++++++++++++++++ 119 files changed, 316 insertions(+), 276 deletions(-) diff --git a/docs/subsystems/approval.i18n.yaml b/docs/subsystems/approval.i18n.yaml index 088b30ff32..7c24a8f465 100644 --- a/docs/subsystems/approval.i18n.yaml +++ b/docs/subsystems/approval.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/approval.md -approval.md: 660ced6d75886bbea41985fd1f357bf92e8a7838 -approval.zh.md: 56975914098978af99580694ca19f20a02ed4022 +approval.md: 6a722c7dcdb78927a23b544f097c60ef412857c3 +approval.zh.md: 673d2f704766e5d2b1c6c28dceedc03d54a98052 diff --git a/docs/subsystems/approval.md b/docs/subsystems/approval.md index 660ced6d75..6a722c7dcd 100644 --- a/docs/subsystems/approval.md +++ b/docs/subsystems/approval.md @@ -141,7 +141,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [Agent](core.md) · [Session](session.md) -Source: [`packages/interaction/user-approval/src/index.ts:192`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts) @@ -166,5 +166,5 @@ Ask composed answerers for one decision. Return an outcome to claim the request Types: [Scoped](scope.md) -Source: [`packages/interaction/user-approval/src/index.ts:30`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts) diff --git a/docs/subsystems/approval.zh.md b/docs/subsystems/approval.zh.md index 5697591409..673d2f7047 100644 --- a/docs/subsystems/approval.zh.md +++ b/docs/subsystems/approval.zh.md @@ -141,7 +141,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [Agent](core.md) · [Session](session.md) -Source: [`packages/interaction/user-approval/src/index.ts:192`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts) @@ -166,5 +166,5 @@ Ask composed answerers for one decision. Return an outcome to claim the request Types: [Scoped](scope.md) -Source: [`packages/interaction/user-approval/src/index.ts:30`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts) diff --git a/docs/subsystems/bash.i18n.yaml b/docs/subsystems/bash.i18n.yaml index dbfc52bf6e..02b4147beb 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: d66cbbccff97c2aab22cc0c415afeabeb3741684 +bash.zh.md: 6f016f8cf9024921bf46ebe3fd2de7451aef4998 diff --git a/docs/subsystems/bash.md b/docs/subsystems/bash.md index d2797c1e3f..d66cbbccff 100644 --- a/docs/subsystems/bash.md +++ b/docs/subsystems/bash.md @@ -266,7 +266,7 @@ abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashProcess ``` -Source: [`packages/bash/bash/src/index.ts:53`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts) @@ -299,5 +299,5 @@ list(): BashEnvVariableInfo[] Types: [DshEnvironment](subprocess.md) · [ToolExecution](tools.md) -Source: [`packages/bash/bash-env/src/index.ts:89`](../../packages/bash/bash-env/src/index.ts) +Source: [`packages/bash/bash-env/src/index.ts`](../../packages/bash/bash-env/src/index.ts) diff --git a/docs/subsystems/bash.zh.md b/docs/subsystems/bash.zh.md index b38a4f3329..6f016f8cf9 100644 --- a/docs/subsystems/bash.zh.md +++ b/docs/subsystems/bash.zh.md @@ -266,7 +266,7 @@ abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashProcess ``` -Source: [`packages/bash/bash/src/index.ts:53`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts) @@ -299,5 +299,5 @@ list(): BashEnvVariableInfo[] Types: [DshEnvironment](subprocess.md) · [ToolExecution](tools.md) -Source: [`packages/bash/bash-env/src/index.ts:89`](../../packages/bash/bash-env/src/index.ts) +Source: [`packages/bash/bash-env/src/index.ts`](../../packages/bash/bash-env/src/index.ts) diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index 8c320d18e6..6239d4724c 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: ccf18d4f217bc4795ea73490a9d009f6292f6c8b +client-modules.zh.md: 4ee3c7461e9378f6f6a1aaac3d839e15eeb4109a diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index 808fa62b2e..ccf18d4f21 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -114,5 +114,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void onGraphChanged(listener: () => void): () => void ``` -Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts) +Source: [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts) diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index dcab60bea7..4ee3c7461e 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -114,5 +114,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void onGraphChanged(listener: () => void): () => void ``` -Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts) +Source: [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts) diff --git a/docs/subsystems/code-runtime.i18n.yaml b/docs/subsystems/code-runtime.i18n.yaml index 47783d5f77..2ac82dad56 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.zh.md: 47516c7d21c24399498a05410ab9a46b61736fb4 +code-runtime.md: a82d79cf231e664caa84f4967dfca0551782ae20 +code-runtime.zh.md: 62f47d14133f13dc0016a8db6b1c8077f7a557f1 diff --git a/docs/subsystems/code-runtime.md b/docs/subsystems/code-runtime.md index 6d3fa7fa72..a82d79cf23 100644 --- a/docs/subsystems/code-runtime.md +++ b/docs/subsystems/code-runtime.md @@ -187,5 +187,5 @@ Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and subs abstract run(request: CodeRunRequest): Promise ``` -Source: [`packages/code-runtime/code-runtime/src/index.ts:102`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts) diff --git a/docs/subsystems/code-runtime.zh.md b/docs/subsystems/code-runtime.zh.md index 47516c7d21..62f47d1413 100644 --- a/docs/subsystems/code-runtime.zh.md +++ b/docs/subsystems/code-runtime.zh.md @@ -187,5 +187,5 @@ Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and subs abstract run(request: CodeRunRequest): Promise ``` -Source: [`packages/code-runtime/code-runtime/src/index.ts:102`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts) diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index 32c5f15c15..f19e2e690d 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/commands.md -commands.md: 5e280d659c2a74b3d8ba20f362922e9eb4e0b84c -commands.zh.md: 24f099d1926f7dba45122bdc53c23a4274e3f507 +commands.md: 7bbe7320886c0ec2346491d2dfe9fa2686a6be80 +commands.zh.md: 06d4e15ffb21908163b07de8152024e624737db0 diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index 5e280d659c..7bbe732088 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -161,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -183,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:134`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index 24f099d192..06d4e15ffb 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -161,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -183,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:134`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index d2fe7303fc..ae26b315db 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: ffdb39fb4a8a304a9fe97afa6d182b15f0d108d3 +compaction.zh.md: b4d85583f30769afa738119eba13606fa2bd1415 diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index fdff28ac2e..ffdb39fb4a 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -192,7 +192,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, Types: [CommandId](commands.md) -Source: [`packages/compact/compact/src/index.ts:96`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts) @@ -234,5 +234,5 @@ pruneSession(session: Session): PruneResult Types: [ContentBlock](llm-streaming.md) · [Session](session.md) -Source: [`packages/compact/compact-tool-result-prune/src/index.ts:44`](../../packages/compact/compact-tool-result-prune/src/index.ts) +Source: [`packages/compact/compact-tool-result-prune/src/index.ts`](../../packages/compact/compact-tool-result-prune/src/index.ts) diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index e4227320d9..b4d85583f3 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -192,7 +192,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, Types: [CommandId](commands.md) -Source: [`packages/compact/compact/src/index.ts:96`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts) @@ -234,5 +234,5 @@ pruneSession(session: Session): PruneResult Types: [ContentBlock](llm-streaming.md) · [Session](session.md) -Source: [`packages/compact/compact-tool-result-prune/src/index.ts:44`](../../packages/compact/compact-tool-result-prune/src/index.ts) +Source: [`packages/compact/compact-tool-result-prune/src/index.ts`](../../packages/compact/compact-tool-result-prune/src/index.ts) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 70ceac3b11..577a6e0f8f 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: 27c4359e360223d336cd94695bb45a79f0fd370c -core.zh.md: 4e7519665b6d9efb8075546d93325debd961905d +core.md: 468f92623c4a1368b02da30a39a8e35f8b13b5fa +core.zh.md: db940e68a876b70fc5a2f639db981ed5def53357 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 27c4359e36..468f92623c 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -336,7 +336,7 @@ currentSelection(): ModelSelection async saveSelection(next: ModelSelection): Promise ``` -Source: [`packages/core/agent-default-model/src/index.ts:64`](../../packages/core/agent-default-model/src/index.ts) +Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts) @@ -375,7 +375,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise @@ -547,7 +547,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) @@ -575,7 +575,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:159`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -597,7 +597,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:168`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -621,7 +621,7 @@ A step or turn errored. The machine reports a failure here even when the error h Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:290`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -645,7 +645,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/runtime-types.ts:197`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -666,7 +666,7 @@ One message was discarded from the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/runtime-types.ts:205`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -687,7 +687,7 @@ One message entered the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/runtime-types.ts:186`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -712,7 +712,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/runtime-types.ts:231`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -738,7 +738,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [LlmCallConfig](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:244`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -767,7 +767,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [LlmFailure](llm-streaming.md) · [ResolvedRetryPolicy](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:260`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -791,7 +791,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:217`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -814,7 +814,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:178`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -845,7 +845,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:278`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -870,5 +870,5 @@ A declarative agent entry failed before it could publish a live agent. Consumers 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void ``` -Source: [`packages/core/agent-loop/src/index.ts:182`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts`](../../packages/core/agent-loop/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 4e7519665b..db940e68a8 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -344,7 +344,7 @@ currentSelection(): ModelSelection async saveSelection(next: ModelSelection): Promise ``` -Source: [`packages/core/agent-default-model/src/index.ts:64`](../../packages/core/agent-default-model/src/index.ts) +Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts) @@ -383,7 +383,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise @@ -555,7 +555,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts) @@ -583,7 +583,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:159`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -605,7 +605,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:168`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -629,7 +629,7 @@ A step or turn errored. The machine reports a failure here even when the error h Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:290`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -653,7 +653,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/runtime-types.ts:197`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -674,7 +674,7 @@ One message was discarded from the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/runtime-types.ts:205`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -695,7 +695,7 @@ One message entered the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/runtime-types.ts:186`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -720,7 +720,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/runtime-types.ts:231`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -746,7 +746,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [LlmCallConfig](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:244`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -775,7 +775,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [LlmFailure](llm-streaming.md) · [ResolvedRetryPolicy](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:260`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -799,7 +799,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:217`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -822,7 +822,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:178`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -853,7 +853,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/runtime-types.ts:278`](../../packages/core/agent/src/runtime-types.ts) +Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) @@ -878,5 +878,5 @@ A declarative agent entry failed before it could publish a live agent. Consumers 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void ``` -Source: [`packages/core/agent-loop/src/index.ts:182`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts`](../../packages/core/agent-loop/src/index.ts) diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 7e9e057c75..e73e4d77db 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: a7a93718bfb942bec51ee0d63e5aac6771a57f87 +credentials.zh.md: 3450ba82da1c749ea4b13e699e7ccba4e108626a diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 0bc2224ac0..a7a93718bf 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -101,7 +101,7 @@ abstract set(ref: CredentialRef, value: string): Promise abstract unset(ref: CredentialRef): Promise ``` -Source: [`packages/credentials/credentials/src/index.ts:77`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) @@ -129,5 +129,5 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o 'credentials/updated'(ref: CredentialRef): void ``` -Source: [`packages/credentials/credentials/src/index.ts:67`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index f236b0b2da..3450ba82da 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -101,7 +101,7 @@ abstract set(ref: CredentialRef, value: string): Promise abstract unset(ref: CredentialRef): Promise ``` -Source: [`packages/credentials/credentials/src/index.ts:77`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) @@ -129,5 +129,5 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o 'credentials/updated'(ref: CredentialRef): void ``` -Source: [`packages/credentials/credentials/src/index.ts:67`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts) diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index fac61bde63..ca09a3aa3f 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: 850ad8ccc43c7fcb93006c7d0ed10c26e77f1763 +filesystem.zh.md: e0a2d4b6277ca6f9e766a7101ffbd1a5f82b4f6d diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index e0edad514b..850ad8ccc4 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -413,7 +413,7 @@ abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: Types: [SandboxExecutionPolicy](sandbox.md) -Source: [`packages/fs/fs/src/index.ts:86`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts) @@ -436,7 +436,7 @@ Single-slot decision for the next FileSystem.editText. Calling `next()` yields a 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> ``` -Source: [`packages/fs/fs/src/index.ts:66`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts) @@ -457,7 +457,7 @@ Record an authoritative positive or negative observation. Listeners must be sync 'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void ``` -Source: [`packages/fs/fs/src/index.ts:76`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts) @@ -477,5 +477,5 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise ``` -Source: [`packages/fs/fs/src/index.ts:58`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts) diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 81c2d87b6a..e0a2d4b627 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -413,7 +413,7 @@ abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: Types: [SandboxExecutionPolicy](sandbox.md) -Source: [`packages/fs/fs/src/index.ts:86`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts) @@ -436,7 +436,7 @@ Single-slot decision for the next FileSystem.editText. Calling `next()` yields a 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> ``` -Source: [`packages/fs/fs/src/index.ts:66`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts) @@ -457,7 +457,7 @@ Record an authoritative positive or negative observation. Listeners must be sync 'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void ``` -Source: [`packages/fs/fs/src/index.ts:76`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts) @@ -477,5 +477,5 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise ``` -Source: [`packages/fs/fs/src/index.ts:58`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts) diff --git a/docs/subsystems/goal.i18n.yaml b/docs/subsystems/goal.i18n.yaml index cdd932726e..e8ddb0489b 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: 7cddef581d15e6c8a635c62a42d413d4c17aafa9 +goal.zh.md: fbd251ed5cb97529250afc70c6e80ba4808d7b04 diff --git a/docs/subsystems/goal.md b/docs/subsystems/goal.md index 6f54a5261c..7cddef581d 100644 --- a/docs/subsystems/goal.md +++ b/docs/subsystems/goal.md @@ -247,7 +247,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView Types: [Agent](core.md) -Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts`](../../packages/goal/goal/src/index.ts) @@ -273,5 +273,5 @@ Goal mutation accepted by one live agent. The matching `goal/change` session eve Types: [Agent](core.md) · [Scoped](scope.md) -Source: [`packages/goal/goal/src/domain.ts:114`](../../packages/goal/goal/src/domain.ts) +Source: [`packages/goal/goal/src/domain.ts`](../../packages/goal/goal/src/domain.ts) diff --git a/docs/subsystems/goal.zh.md b/docs/subsystems/goal.zh.md index ea5a0fe792..fbd251ed5c 100644 --- a/docs/subsystems/goal.zh.md +++ b/docs/subsystems/goal.zh.md @@ -247,7 +247,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView Types: [Agent](core.md) -Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts`](../../packages/goal/goal/src/index.ts) @@ -273,5 +273,5 @@ Goal mutation accepted by one live agent. The matching `goal/change` session eve Types: [Agent](core.md) · [Scoped](scope.md) -Source: [`packages/goal/goal/src/domain.ts:114`](../../packages/goal/goal/src/domain.ts) +Source: [`packages/goal/goal/src/domain.ts`](../../packages/goal/goal/src/domain.ts) diff --git a/docs/subsystems/http-server.i18n.yaml b/docs/subsystems/http-server.i18n.yaml index 6f61102676..5c03d2668d 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: c6164be82e3b3bb85ec2239f49b0baebe7189f91 +http-server.zh.md: 726c72faa543116d38f518b56c70aefdfa19864f diff --git a/docs/subsystems/http-server.md b/docs/subsystems/http-server.md index b9795fe98b..c6164be82e 100644 --- a/docs/subsystems/http-server.md +++ b/docs/subsystems/http-server.md @@ -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`](../../packages/host/webserver/src/index.ts) diff --git a/docs/subsystems/http-server.zh.md b/docs/subsystems/http-server.zh.md index 8d55c48ae2..726c72faa5 100644 --- a/docs/subsystems/http-server.zh.md +++ b/docs/subsystems/http-server.zh.md @@ -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`](../../packages/host/webserver/src/index.ts) diff --git a/docs/subsystems/invariants.i18n.yaml b/docs/subsystems/invariants.i18n.yaml index 2ff4b67060..c19f608a3b 100644 --- a/docs/subsystems/invariants.i18n.yaml +++ b/docs/subsystems/invariants.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/invariants.md -invariants.md: ccf81f0252dd2db4eec666df6fab8c7d6a830f25 -invariants.zh.md: 046aa4a56d48fa0386fba3a51d850ab21ce5145d +invariants.md: 1fcd17292655446ee8480e290d6a4cc5f13306ea +invariants.zh.md: e705e99273fcd43fec10d17f9f886bcb3e8c8ca5 diff --git a/docs/subsystems/invariants.md b/docs/subsystems/invariants.md index ccf81f0252..1fcd172926 100644 --- a/docs/subsystems/invariants.md +++ b/docs/subsystems/invariants.md @@ -84,5 +84,5 @@ Package-owned invariant registry with global and regex-based selection. register(packageName: string, installer: InvariantInstaller): () => void ``` -Source: [`packages/support/invariants/src/index.ts:94`](../../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts`](../../packages/support/invariants/src/index.ts) diff --git a/docs/subsystems/invariants.zh.md b/docs/subsystems/invariants.zh.md index 046aa4a56d..e705e99273 100644 --- a/docs/subsystems/invariants.zh.md +++ b/docs/subsystems/invariants.zh.md @@ -84,5 +84,5 @@ Package-owned invariant registry with global and regex-based selection. register(packageName: string, installer: InvariantInstaller): () => void ``` -Source: [`packages/support/invariants/src/index.ts:94`](../../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts`](../../packages/support/invariants/src/index.ts) diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 86ec4e3c3f..49942dab93 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: 4d450c19ec2bbfacabcefc83466e67c8a6c82bd6 -llm-streaming.zh.md: 777c44eaff2b1ee6e5939e04e580d6ad34b1ad1a +llm-streaming.md: d7e0ef6ddc2e98a79d8c923ed19860dc02cfd575 +llm-streaming.zh.md: 580fd06ba616f08906516196063e8f6553396f0b diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 4d450c19ec..d7e0ef6ddc 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -830,7 +830,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Source: [`packages/llm/llm/src/index.ts:292`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) @@ -855,7 +855,7 @@ The provider topology changed: an adapter registered or unregistered routes, or 'llm/adapters-updated'(): void ``` -Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) @@ -879,5 +879,5 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` -Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 777c44eaff..580fd06ba6 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -838,7 +838,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Source: [`packages/llm/llm/src/index.ts:292`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) @@ -863,7 +863,7 @@ The provider topology changed: an adapter registered or unregistered routes, or 'llm/adapters-updated'(): void ``` -Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) @@ -887,5 +887,5 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` -Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) diff --git a/docs/subsystems/permission.i18n.yaml b/docs/subsystems/permission.i18n.yaml index 3f9c9b533c..3644c5dbec 100644 --- a/docs/subsystems/permission.i18n.yaml +++ b/docs/subsystems/permission.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/permission.md -permission.md: 6f9a37dbb784ccbaea74056519992e40f9c07c6d -permission.zh.md: 8eda50fae09a67c41003d7a914fb139c4132a900 +permission.md: 0b9c155adb777059b53c9378f8d07fffaff65ab8 +permission.zh.md: b804804ca1d56a6e9f27bc1fa6bbdcda6a93420f diff --git a/docs/subsystems/permission.md b/docs/subsystems/permission.md index 6f9a37dbb7..0b9c155adb 100644 --- a/docs/subsystems/permission.md +++ b/docs/subsystems/permission.md @@ -127,5 +127,5 @@ set(session: Session, name: string): void Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/interaction/permission/src/index.ts:159`](../../packages/interaction/permission/src/index.ts) +Source: [`packages/interaction/permission/src/index.ts`](../../packages/interaction/permission/src/index.ts) diff --git a/docs/subsystems/permission.zh.md b/docs/subsystems/permission.zh.md index 8eda50fae0..b804804ca1 100644 --- a/docs/subsystems/permission.zh.md +++ b/docs/subsystems/permission.zh.md @@ -127,5 +127,5 @@ set(session: Session, name: string): void Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/interaction/permission/src/index.ts:159`](../../packages/interaction/permission/src/index.ts) +Source: [`packages/interaction/permission/src/index.ts`](../../packages/interaction/permission/src/index.ts) diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index f198d0a25e..56de498b2f 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: 640e2c0122b01ae869d20c1805742abad782c95b -persistence.zh.md: 7e28d6d1f63a78741840fb74194d3249696423ae +persistence.md: 4c1c5f7ef706e97bf2248c20831accd92e2ff55d +persistence.zh.md: d1fdef31f164a3a3c2c78804bfc6156fdf3fcd92 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 640e2c0122..4c1c5f7ef7 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -334,5 +334,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 7e28d6d1f6..d1fdef31f1 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -334,5 +334,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index 2286bad3fd..2873cc7431 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: 2f5c74a281131c292fba890869a707ecc628e8ed +plan.zh.md: 3351544758a712ac0f3a0585fc771b0057ecd98e diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index 29a2473c44..2f5c74a281 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -82,5 +82,5 @@ 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`](../../packages/plan/plan-mode/src/index.ts) diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index bcb3277191..3351544758 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -82,5 +82,5 @@ 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`](../../packages/plan/plan-mode/src/index.ts) diff --git a/docs/subsystems/pty.i18n.yaml b/docs/subsystems/pty.i18n.yaml index 76b4500aea..90fe856de7 100644 --- a/docs/subsystems/pty.i18n.yaml +++ b/docs/subsystems/pty.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/pty.md -pty.md: 45b157c73926d85d5fc2bb037d3954da7bf75063 -pty.zh.md: 4409ef0b0504780b0b5564a34b61d4f1b2c02775 +pty.md: a7e93bd46a5fea824bbbb7d5aa38c3cd257cc5ce +pty.zh.md: b783a08496f1ee28c1cdbf7e1aa7ba03b3bee392 diff --git a/docs/subsystems/pty.md b/docs/subsystems/pty.md index 45b157c739..a7e93bd46a 100644 --- a/docs/subsystems/pty.md +++ b/docs/subsystems/pty.md @@ -180,5 +180,5 @@ list(owner: Agent): PtySessionSnapshot[] Types: [Agent](core.md) -Source: [`packages/pty/pty/src/index.ts:105`](../../packages/pty/pty/src/index.ts) +Source: [`packages/pty/pty/src/index.ts`](../../packages/pty/pty/src/index.ts) diff --git a/docs/subsystems/pty.zh.md b/docs/subsystems/pty.zh.md index 4409ef0b05..b783a08496 100644 --- a/docs/subsystems/pty.zh.md +++ b/docs/subsystems/pty.zh.md @@ -180,5 +180,5 @@ list(owner: Agent): PtySessionSnapshot[] Types: [Agent](core.md) -Source: [`packages/pty/pty/src/index.ts:105`](../../packages/pty/pty/src/index.ts) +Source: [`packages/pty/pty/src/index.ts`](../../packages/pty/pty/src/index.ts) diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index a6f8fd2609..1e04bca9a0 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sandbox.md -sandbox.md: dd960b3021dcdc87cfd36fd439cbec0a810dd736 -sandbox.zh.md: 23644bb43a131a0e3c8595187a6fc11e74682d9e +sandbox.md: 0973984b52f246e3fd982183439c64f0cf5e6c99 +sandbox.zh.md: e8bfb53c52e691b1c5027d687bc8e4a3d735c018 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index dd960b3021..0973984b52 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -176,7 +176,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` -Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts) @@ -206,5 +206,5 @@ overrideOf(session: Session): SandboxMode | undefined Types: [Session](session.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:91`](../../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts`](../../packages/sandbox/sandbox-policy/src/index.ts) diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 23644bb43a..e8bfb53c52 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -176,7 +176,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` -Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts) @@ -206,5 +206,5 @@ overrideOf(session: Session): SandboxMode | undefined Types: [Session](session.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:91`](../../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts`](../../packages/sandbox/sandbox-policy/src/index.ts) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 823ed61107..fa23022abb 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: d91a3faa50dc092d89aa2c7d5ce1e6118df7ebd6 -session-projection.zh.md: 7dac3db8470a2941b711db6a30fced8dbe7c7a8d +session-projection.md: a9851a016713a1e2019354a9b1fc26f018321f45 +session-projection.zh.md: a1c9fa61d7108fc545aa7ee513d8516c10b79179 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index d91a3faa50..a9851a0167 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -148,7 +148,7 @@ async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise @@ -258,5 +258,5 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:156`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 7dac3db847..a1c9fa61d7 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -148,7 +148,7 @@ async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise @@ -258,5 +258,5 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:156`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-query.i18n.yaml b/docs/subsystems/session-query.i18n.yaml index 4ba7a3392b..aabf674f67 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: 2c9791aa7bfcb74f38a7554f619b7f0a1e2376d3 +session-query.zh.md: 3b968f1de674c1308484c24290496e78e688ddd9 diff --git a/docs/subsystems/session-query.md b/docs/subsystems/session-query.md index 3b6d99af58..2c9791aa7b 100644 --- a/docs/subsystems/session-query.md +++ b/docs/subsystems/session-query.md @@ -490,5 +490,5 @@ async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise Types: [SessionId](core.md) · [SessionTitleSnapshot](session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:81`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts`](../../packages/session-query/session-query/src/index.ts) diff --git a/docs/subsystems/session-query.zh.md b/docs/subsystems/session-query.zh.md index c23e9ad53e..3b968f1de6 100644 --- a/docs/subsystems/session-query.zh.md +++ b/docs/subsystems/session-query.zh.md @@ -490,5 +490,5 @@ async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise Types: [SessionId](core.md) · [SessionTitleSnapshot](session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:81`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts`](../../packages/session-query/session-query/src/index.ts) diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index a73c858289..f400e35b10 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: 4dc3ef117fe2f04dd3f64c80cfc9547f984bd0b5 +session-reference.zh.md: c0c1f7830fa93c472f30aeb7742fd2008d01c297 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index f539a59b8d..4dc3ef117f 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -104,5 +104,5 @@ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferen Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) -Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +Source: [`packages/context/session-reference/src/index.ts`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 1035614141..c0c1f7830f 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -104,5 +104,5 @@ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferen Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) -Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +Source: [`packages/context/session-reference/src/index.ts`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/session-title.i18n.yaml b/docs/subsystems/session-title.i18n.yaml index 29804cae7e..c6a771b927 100644 --- a/docs/subsystems/session-title.i18n.yaml +++ b/docs/subsystems/session-title.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-title.md -session-title.md: 339177b03a03b7d82375f7a293934cb164e9f266 -session-title.zh.md: b1353025a4f621e9a3117c0406ad304c35828a49 +session-title.md: 05edbb380d76aaadd9f4798bea52f13b366f2b0b +session-title.zh.md: 5396f86b71abc47ba7fdc647e9fd3a98e5d2ff25 diff --git a/docs/subsystems/session-title.md b/docs/subsystems/session-title.md index 339177b03a..05edbb380d 100644 --- a/docs/subsystems/session-title.md +++ b/docs/subsystems/session-title.md @@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](session.md) -Source: [`packages/session/session-title/src/index.ts:261`](../../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts`](../../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/session-title.zh.md b/docs/subsystems/session-title.zh.md index b1353025a4..5396f86b71 100644 --- a/docs/subsystems/session-title.zh.md +++ b/docs/subsystems/session-title.zh.md @@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](session.md) -Source: [`packages/session/session-title/src/index.ts:261`](../../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts`](../../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 3b9f9ce01d..bb15a107e5 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: f5b9e63e2320885cc41b30a09398dd341700152d -session.zh.md: 985e0a448d1cf860ccbb0f2885d855ad6830af9f +session.md: cb9530db52adb679d1a6cf1926e681475592bba8 +session.zh.md: d2ed317129c137e13c008a719cff4dc2df7e0678 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index f5b9e63e23..cb9530db52 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -733,7 +733,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:807`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts`](../../packages/core/session/src/index.ts) @@ -762,7 +762,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`](../../packages/core/session/src/index.ts) @@ -785,7 +785,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`](../../packages/core/session/src/index.ts) @@ -810,7 +810,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`](../../packages/core/session/src/index.ts) @@ -832,5 +832,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`](../../packages/core/session/src/index.ts) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 985e0a448d..d2ed317129 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -737,7 +737,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:807`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts`](../../packages/core/session/src/index.ts) @@ -766,7 +766,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`](../../packages/core/session/src/index.ts) @@ -789,7 +789,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`](../../packages/core/session/src/index.ts) @@ -814,7 +814,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`](../../packages/core/session/src/index.ts) @@ -836,5 +836,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`](../../packages/core/session/src/index.ts) diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index a257e0e33e..c7bf36c066 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: 25c56946016c5d108743e2af1f4785ec63501c80 +settings.zh.md: 697f87e7c27f0613042bb05b835de7bd9e56acf6 diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index 9256bf9436..25c5694601 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -252,7 +252,7 @@ async replace(ns: SettingsNamespace, section: object, expectedRevision?: number) async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise ``` -Source: [`packages/settings/settings/src/index.ts:387`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) @@ -279,7 +279,7 @@ One registered namespace's RAW user section changed, whether or not the resolved 'settings/document-updated'(ns: SettingsNamespace, revision: number): void ``` -Source: [`packages/settings/settings/src/index.ts:170`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) @@ -306,5 +306,5 @@ Committed change to one registered namespace's resolved value. Emitted after the 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void ``` -Source: [`packages/settings/settings/src/index.ts:157`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 720eb9c271..697f87e7c2 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -252,7 +252,7 @@ async replace(ns: SettingsNamespace, section: object, expectedRevision?: number) async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise ``` -Source: [`packages/settings/settings/src/index.ts:387`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) @@ -279,7 +279,7 @@ One registered namespace's RAW user section changed, whether or not the resolved 'settings/document-updated'(ns: SettingsNamespace, revision: number): void ``` -Source: [`packages/settings/settings/src/index.ts:170`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) @@ -306,5 +306,5 @@ Committed change to one registered namespace's resolved value. Emitted after the 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void ``` -Source: [`packages/settings/settings/src/index.ts:157`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts) diff --git a/docs/subsystems/skills.i18n.yaml b/docs/subsystems/skills.i18n.yaml index a169374140..c86747102f 100644 --- a/docs/subsystems/skills.i18n.yaml +++ b/docs/subsystems/skills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/skills.md -skills.md: 696f759179203230bf748c8a4be2cee333acd9a3 -skills.zh.md: d2244e86f0ceaf77c1eab02508dc3f07df727dbb +skills.md: 010b2e3ea1ef96d8509761973bf7223f20659eb1 +skills.zh.md: f48ab74b4f85aafcbdc07814e4cafb2fed2832dd diff --git a/docs/subsystems/skills.md b/docs/subsystems/skills.md index 696f759179..010b2e3ea1 100644 --- a/docs/subsystems/skills.md +++ b/docs/subsystems/skills.md @@ -283,7 +283,7 @@ async snapshot(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -Source: [`packages/skill/skill/src/index.ts:305`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts) @@ -306,5 +306,5 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:284`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts) diff --git a/docs/subsystems/skills.zh.md b/docs/subsystems/skills.zh.md index d2244e86f0..f48ab74b4f 100644 --- a/docs/subsystems/skills.zh.md +++ b/docs/subsystems/skills.zh.md @@ -283,7 +283,7 @@ async snapshot(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -Source: [`packages/skill/skill/src/index.ts:305`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts) @@ -306,5 +306,5 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:284`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts) diff --git a/docs/subsystems/spill.i18n.yaml b/docs/subsystems/spill.i18n.yaml index da210bd7d2..56104d33d5 100644 --- a/docs/subsystems/spill.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/spill.md -spill.md: bc323f8d2591e93a56e938c245274ef6be86085f -spill.zh.md: f81d5e5de3633561a5a090a14c39b43268b8c63f +spill.md: 51b7c167150c614d33db970c516ca603a04b2782 +spill.zh.md: 338af03316c9c03903bf0342c4d863fff7728f73 diff --git a/docs/subsystems/spill.md b/docs/subsystems/spill.md index bc323f8d25..51b7c16715 100644 --- a/docs/subsystems/spill.md +++ b/docs/subsystems/spill.md @@ -113,5 +113,5 @@ Semantics every implementation must honor: abstract saveText(input: SaveTextSpill): Promise ``` -Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/index.ts) +Source: [`packages/spill/spill/src/index.ts`](../../packages/spill/spill/src/index.ts) diff --git a/docs/subsystems/spill.zh.md b/docs/subsystems/spill.zh.md index f81d5e5de3..338af03316 100644 --- a/docs/subsystems/spill.zh.md +++ b/docs/subsystems/spill.zh.md @@ -113,5 +113,5 @@ Semantics every implementation must honor: abstract saveText(input: SaveTextSpill): Promise ``` -Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/index.ts) +Source: [`packages/spill/spill/src/index.ts`](../../packages/spill/spill/src/index.ts) diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 9b3d8a19d6..ca283d72ab 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: 6947c23fedc4adab3364196c9b3c30618e96921e +storage.zh.md: b2c43d0f6ad319ec0f85320bed2d38d9507cfdc5 diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 3234fdf958..6947c23fed 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -156,7 +156,7 @@ mount(form: K, facility: StorageForms[K]): () => v form(form: K): StorageForms[K] ``` -Source: [`packages/storage/storage/src/index.ts:47`](../../packages/storage/storage/src/index.ts) +Source: [`packages/storage/storage/src/index.ts`](../../packages/storage/storage/src/index.ts) @@ -201,7 +201,7 @@ get(name: string): DomainImpl | undefined async closeAll(): Promise ``` -Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/storage/storage-domain/src/index.ts) +Source: [`packages/storage/storage-domain/src/index.ts`](../../packages/storage/storage-domain/src/index.ts) @@ -225,5 +225,5 @@ A domain record or the global singleton changed, emitted once per write strictly 'domain/changed'(change: DomainChanged): void ``` -Source: [`packages/storage/storage-domain/src/events.ts:46`](../../packages/storage/storage-domain/src/events.ts) +Source: [`packages/storage/storage-domain/src/events.ts`](../../packages/storage/storage-domain/src/events.ts) diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index fb13ec9609..b2c43d0f6a 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -156,7 +156,7 @@ mount(form: K, facility: StorageForms[K]): () => v form(form: K): StorageForms[K] ``` -Source: [`packages/storage/storage/src/index.ts:47`](../../packages/storage/storage/src/index.ts) +Source: [`packages/storage/storage/src/index.ts`](../../packages/storage/storage/src/index.ts) @@ -201,7 +201,7 @@ get(name: string): DomainImpl | undefined async closeAll(): Promise ``` -Source: [`packages/storage/storage-domain/src/index.ts:69`](../../packages/storage/storage-domain/src/index.ts) +Source: [`packages/storage/storage-domain/src/index.ts`](../../packages/storage/storage-domain/src/index.ts) @@ -225,5 +225,5 @@ A domain record or the global singleton changed, emitted once per write strictly 'domain/changed'(change: DomainChanged): void ``` -Source: [`packages/storage/storage-domain/src/events.ts:46`](../../packages/storage/storage-domain/src/events.ts) +Source: [`packages/storage/storage-domain/src/events.ts`](../../packages/storage/storage-domain/src/events.ts) diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index e98438900d..d1691920e9 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: 89df677c991a52ba88f2811e014b9b1131f7385d +subagent.zh.md: 99a11df9cc842969a6fd8a30f6d6887d98a2e79f diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 99c54e0696..89df677c99 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`](../../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`](../../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`](../../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`](../../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`](../../packages/subagent/subagent/src/index.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index c5a79247a8..99a11df9cc 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`](../../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`](../../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`](../../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`](../../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`](../../packages/subagent/subagent/src/index.ts) diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 26064089c2..25daa209e9 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/subprocess.md -subprocess.md: 4fb20e1b440b8fc10540fa2adbd00ae63fd7e221 -subprocess.zh.md: a41f14aa550c4780b5661b546880d932d513b817 +subprocess.md: e7adb0fbcc1565b6ba17e8af6c70fc43d06faf20 +subprocess.zh.md: dd9ec8e8a9749c2d782683ecf34c4249acbc3e3d diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 4fb20e1b44..e7adb0fbcc 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -271,7 +271,7 @@ Creates one lazily consumable E2B SDK handle and deletes the sandbox at timeout async getSandbox(): Promise ``` -Source: [`packages/e2b/e2b/src/index.ts:74`](../../packages/e2b/e2b/src/index.ts) +Source: [`packages/e2b/e2b/src/index.ts`](../../packages/e2b/e2b/src/index.ts) @@ -320,5 +320,5 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise ``` -Source: [`packages/subprocess/subprocess/src/index.ts:102`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts`](../../packages/subprocess/subprocess/src/index.ts) diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index a41f14aa55..dd9ec8e8a9 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -271,7 +271,7 @@ Creates one lazily consumable E2B SDK handle and deletes the sandbox at timeout async getSandbox(): Promise ``` -Source: [`packages/e2b/e2b/src/index.ts:74`](../../packages/e2b/e2b/src/index.ts) +Source: [`packages/e2b/e2b/src/index.ts`](../../packages/e2b/e2b/src/index.ts) @@ -320,5 +320,5 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise ``` -Source: [`packages/subprocess/subprocess/src/index.ts:102`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts`](../../packages/subprocess/subprocess/src/index.ts) diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index a7a5e5a6d0..e3a5dd5bec 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: 94ce40f8bf98dd4efe3514879c2527c2a7bd3b21 -system-prompt.zh.md: c46ee6e2b70c6603500bd9061ed09b805ed61630 +system-prompt.md: a6267c5e081662f19a083b78e9dec5a99f335075 +system-prompt.zh.md: 88b26796769f028e826c769a60b71c8275bef0c7 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index 94ce40f8bf..a6267c5e08 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -139,7 +139,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:314`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts) @@ -167,7 +167,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`](../../packages/core/system-prompt/src/index.ts) @@ -184,5 +184,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`](../../packages/core/system-prompt/src/index.ts) diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index c46ee6e2b7..88b2679676 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -139,7 +139,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:314`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts) @@ -167,7 +167,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`](../../packages/core/system-prompt/src/index.ts) @@ -184,5 +184,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`](../../packages/core/system-prompt/src/index.ts) diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index c229d0a5c0..24e8422287 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: 3e008cdcbec79054b84a04b10b41df88efaa540c +tasks.zh.md: 51d8ce0171534071ea01270df51f519711c5cc70 diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index d3e92891a6..3e008cdcbe 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -256,5 +256,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`](../../packages/tasks/tasks/src/index.ts) diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index fe54d7a648..51d8ce0171 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -256,5 +256,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`](../../packages/tasks/tasks/src/index.ts) diff --git a/docs/subsystems/telemetry.i18n.yaml b/docs/subsystems/telemetry.i18n.yaml index c994c46baa..f50ba4311f 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: d548d4354e83f6c4d13d4f747339f4943c7f067b +telemetry.zh.md: 167cfb956cdc9e707007bde53e982ec015a946c8 diff --git a/docs/subsystems/telemetry.md b/docs/subsystems/telemetry.md index 93869f2344..d548d4354e 100644 --- a/docs/subsystems/telemetry.md +++ b/docs/subsystems/telemetry.md @@ -142,7 +142,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:140`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -176,5 +176,5 @@ Transform one outbound record before it reaches the backend. This waterfall is t 'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/session/session-telemetry/src/index.ts:43`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) diff --git a/docs/subsystems/telemetry.zh.md b/docs/subsystems/telemetry.zh.md index 50642f1ed9..167cfb956c 100644 --- a/docs/subsystems/telemetry.zh.md +++ b/docs/subsystems/telemetry.zh.md @@ -142,7 +142,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:140`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -176,5 +176,5 @@ Transform one outbound record before it reaches the backend. This waterfall is t 'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/session/session-telemetry/src/index.ts:43`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) diff --git a/docs/subsystems/token-meter.i18n.yaml b/docs/subsystems/token-meter.i18n.yaml index 17bbb937cc..375c53e4af 100644 --- a/docs/subsystems/token-meter.i18n.yaml +++ b/docs/subsystems/token-meter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/token-meter.md -token-meter.md: 8a01980191bf29b1fbc0f8b1d0b33d1fb149319c -token-meter.zh.md: 7411ce26cb75697607a706469d7923d46ff3f421 +token-meter.md: 3eff5798788703053fe2b5e6623afc65653af8a3 +token-meter.zh.md: f76d451520a8f6fb97ecf5b5ded22f459af3b471 diff --git a/docs/subsystems/token-meter.md b/docs/subsystems/token-meter.md index 8a01980191..3eff579878 100644 --- a/docs/subsystems/token-meter.md +++ b/docs/subsystems/token-meter.md @@ -86,5 +86,5 @@ estimateMessage(message: Message): number Types: [EpochHeader](session.md) · [Message](llm-streaming.md) · [Session](session.md) -Source: [`packages/llm/token-meter/src/index.ts:74`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts`](../../packages/llm/token-meter/src/index.ts) diff --git a/docs/subsystems/token-meter.zh.md b/docs/subsystems/token-meter.zh.md index 7411ce26cb..f76d451520 100644 --- a/docs/subsystems/token-meter.zh.md +++ b/docs/subsystems/token-meter.zh.md @@ -86,5 +86,5 @@ estimateMessage(message: Message): number Types: [EpochHeader](session.md) · [Message](llm-streaming.md) · [Session](session.md) -Source: [`packages/llm/token-meter/src/index.ts:74`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts`](../../packages/llm/token-meter/src/index.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 1384b3888b..6be79a2f66 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: 83389f39c3188fc251504ed5786249ff1921acae -tools.zh.md: 45cb85b3f2940f84b46bc58406bc8255cbe08be7 +tools.md: bae35a2d2c5b9857fd646a0ee873d468818ebc08 +tools.zh.md: c14a35d62683eb26c7403234740d32c33ed68a6c diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 83389f39c3..bae35a2d2c 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -554,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) @@ -579,7 +579,7 @@ 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`](../../packages/core/tools/src/index.ts) @@ -605,7 +605,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`](../../packages/core/tools/src/index.ts) @@ -629,7 +629,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) @@ -654,7 +654,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) @@ -677,7 +677,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) @@ -698,5 +698,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`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 45cb85b3f2..c14a35d626 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -554,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) @@ -579,7 +579,7 @@ 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`](../../packages/core/tools/src/index.ts) @@ -605,7 +605,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`](../../packages/core/tools/src/index.ts) @@ -629,7 +629,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) @@ -654,7 +654,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) @@ -677,7 +677,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) @@ -698,5 +698,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`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index d58e6036ab..a60ced9485 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/typert.md -typert.md: 8ce52837ceee8e9c8c4a61de28f972912faa2cf0 -typert.zh.md: a025716dad9a9279a45530a0a9109a13a2c34b0c +typert.md: 9ea963e9a28f0b5e186b00de75e4103baaf3501d +typert.zh.md: 781641ae9f63e85cf9518717244d685bdfa64331 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 8ce52837ce..9ea963e9a2 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -274,7 +274,7 @@ toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema Types: [TypertContribution](invariants.md) · [TypertFace](invariants.md) · [TypertPackageFilter](invariants.md) · [TypertPackageRecord](invariants.md) · [TypertSchemaFilter](invariants.md) · [TypertSchemaRecord](invariants.md) -Source: [`packages/typert/registry/src/service.ts:446`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts`](../../packages/typert/registry/src/service.ts) @@ -292,5 +292,5 @@ Resolve strict generated definitions or conservative SRC markers against current async invoke(request: InvokeRemoteRequest): Promise ``` -Source: [`packages/api/gateway/src/index.ts:78`](../../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts`](../../packages/api/gateway/src/index.ts) diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index a025716dad..781641ae9f 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -274,7 +274,7 @@ toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema Types: [TypertContribution](invariants.md) · [TypertFace](invariants.md) · [TypertPackageFilter](invariants.md) · [TypertPackageRecord](invariants.md) · [TypertSchemaFilter](invariants.md) · [TypertSchemaRecord](invariants.md) -Source: [`packages/typert/registry/src/service.ts:446`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts`](../../packages/typert/registry/src/service.ts) @@ -292,5 +292,5 @@ Resolve strict generated definitions or conservative SRC markers against current async invoke(request: InvokeRemoteRequest): Promise ``` -Source: [`packages/api/gateway/src/index.ts:78`](../../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts`](../../packages/api/gateway/src/index.ts) diff --git a/docs/subsystems/user-interaction.i18n.yaml b/docs/subsystems/user-interaction.i18n.yaml index 296bc8b766..f538f623ef 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: 5860fbf2d7c7cab404b2586a91e69f17787b4f76 +user-interaction.zh.md: 7a1908184a1c3a27fa7db9395d341ded07ea1c93 diff --git a/docs/subsystems/user-interaction.md b/docs/subsystems/user-interaction.md index a19155ae06..5860fbf2d7 100644 --- a/docs/subsystems/user-interaction.md +++ b/docs/subsystems/user-interaction.md @@ -174,5 +174,5 @@ registerProvider(provider: UserInteractionProvider): () => void async ask(request: AskUserQuestionRequest): Promise ``` -Source: [`packages/interaction/user-interaction/src/index.ts:51`](../../packages/interaction/user-interaction/src/index.ts) +Source: [`packages/interaction/user-interaction/src/index.ts`](../../packages/interaction/user-interaction/src/index.ts) diff --git a/docs/subsystems/user-interaction.zh.md b/docs/subsystems/user-interaction.zh.md index c7aa9cafc9..7a1908184a 100644 --- a/docs/subsystems/user-interaction.zh.md +++ b/docs/subsystems/user-interaction.zh.md @@ -174,5 +174,5 @@ registerProvider(provider: UserInteractionProvider): () => void async ask(request: AskUserQuestionRequest): Promise ``` -Source: [`packages/interaction/user-interaction/src/index.ts:51`](../../packages/interaction/user-interaction/src/index.ts) +Source: [`packages/interaction/user-interaction/src/index.ts`](../../packages/interaction/user-interaction/src/index.ts) diff --git a/docs/subsystems/web.i18n.yaml b/docs/subsystems/web.i18n.yaml index 8a11d774e6..9e9b98c697 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: 43b9d378a1d81063111c9df4174ba16e3f49ad9f +web.zh.md: 80dee03e0037cd2515ab49ba0a425312f2d87813 diff --git a/docs/subsystems/web.md b/docs/subsystems/web.md index 2a7cc499b4..43b9d378a1 100644 --- a/docs/subsystems/web.md +++ b/docs/subsystems/web.md @@ -195,5 +195,5 @@ async search(request: WebSearchRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/web/web/src/index.ts:74`](../../packages/web/web/src/index.ts) +Source: [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts) diff --git a/docs/subsystems/web.zh.md b/docs/subsystems/web.zh.md index 6857f2a06a..80dee03e00 100644 --- a/docs/subsystems/web.zh.md +++ b/docs/subsystems/web.zh.md @@ -195,5 +195,5 @@ async search(request: WebSearchRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/web/web/src/index.ts:74`](../../packages/web/web/src/index.ts) +Source: [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts) diff --git a/docs/subsystems/workflow.i18n.yaml b/docs/subsystems/workflow.i18n.yaml index f1ddf20d11..7e69a5497a 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: 5e8b4a8d8fb73a7ef404305a27e297df824c7dd9 +workflow.zh.md: 14d4a443d8ba26ffa2c16064eed86b7b1bbf32d9 diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 4c552b3143..5e8b4a8d8f 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -155,7 +155,7 @@ Workflow Service Definition contract. Invalid requests throw before publication; abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -181,7 +181,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -202,7 +202,7 @@ One `agent()` call established a published child run. Paired with Events['workfl 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -223,7 +223,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -241,7 +241,7 @@ The script emitted a narration line (a `log(message)` call). 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -260,7 +260,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -278,5 +278,5 @@ A workflow run started — the script's meta block validated, the body about to 'workflow/start'(info: WorkflowRunInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) diff --git a/docs/subsystems/workflow.zh.md b/docs/subsystems/workflow.zh.md index 53ef84134b..14d4a443d8 100644 --- a/docs/subsystems/workflow.zh.md +++ b/docs/subsystems/workflow.zh.md @@ -155,7 +155,7 @@ Workflow Service Definition contract. Invalid requests throw before publication; abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -181,7 +181,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -202,7 +202,7 @@ One `agent()` call established a published child run. Paired with Events['workfl 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -223,7 +223,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -241,7 +241,7 @@ The script emitted a narration line (a `log(message)` call). 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -260,7 +260,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) @@ -278,5 +278,5 @@ A workflow run started — the script's meta block validated, the body about to 'workflow/start'(info: WorkflowRunInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts`](../../packages/workflow/workflow/src/index.ts) diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index b2c2248876..72fec29277 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/workspace.md -workspace.md: ca088a2091a7f47a3d52992fec13fae44061a608 -workspace.zh.md: e414c759a043f934e1a8b5d89c7a3b6101bbb6f4 +workspace.md: b23e38be71e902da60794f1287bad1f9f18558a3 +workspace.zh.md: 8843ff5ef1968e3a140d4fc382c46deb7a8807ee diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index ca088a2091..b23e38be71 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -147,7 +147,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts) @@ -215,5 +215,5 @@ async resolveByPath(path: string): Promise Types: [SessionId](core.md) -Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts`](../../packages/workspace/workspace/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index e414c759a0..8843ff5ef1 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -147,7 +147,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts) @@ -215,5 +215,5 @@ async resolveByPath(path: string): Promise Types: [SessionId](core.md) -Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts`](../../packages/workspace/workspace/src/index.ts) diff --git a/packages/typert/generator/src/cordis-catalog.ts b/packages/typert/generator/src/cordis-catalog.ts index 1950c255cb..8f8520d22b 100644 --- a/packages/typert/generator/src/cordis-catalog.ts +++ b/packages/typert/generator/src/cordis-catalog.ts @@ -734,6 +734,12 @@ function anchorFor(headingText: string): string[] { return [``, ''] } +/** Render a subsystem `file:line` source pointer as a file-only link. */ +function sourceLink(source: string): string { + const file = source.split(':')[0] + return `[\`${file}\`](../../${file})` +} + /** Render one harness event entry onto its owning page, nested under its scope heading. */ function renderEvent(e: EventEntry, onPage: string, linkedTypePages: Readonly>): string[] { const out = [...anchorFor(`${e.name} — ${e.mode}`), `#### \`${e.name}\` — ${e.mode}`, ''] @@ -741,7 +747,7 @@ function renderEvent(e: EventEntry, onPage: string, linkedTypePages: Readonly method.signature).join('\n'), onPage, linkedTypePages) if (links) out.push(links, '') } - out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '') + out.push(`Source: ${sourceLink(s.source)}`, '') return out } diff --git a/packages/typert/generator/tests/cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog.spec.ts index 9474c15145..28fa27bbe8 100644 --- a/packages/typert/generator/tests/cordis-catalog.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog.spec.ts @@ -5,12 +5,46 @@ import { projectCordisCatalog, renderInheritedPage, renderPageRegion, + type CordisCatalogPolicy, } from '../src/cordis-catalog.ts' import { CORDIS_CATALOG_POLICY, EVENT_SCOPE_PAGE, REGION_BEGIN, REGION_END, SERVICE_PAGE } from '../../../../scripts/gen-cordis-catalog.ts' const workspaceRoot = resolve(import.meta.dirname, '../../../..') +const SOURCE_LINK_POLICY: CordisCatalogPolicy = { + linkedTypePages: {}, + foundationTypeNames: new Set(), + typeLinkExemptions: {}, + inheritedEvents: [{ name: 'ready', summary: 'Ready.', source: 'vendor/cordis/src/events.ts:9' }], + inheritedServices: [{ name: 'ctx.root', summary: 'Root.', source: 'vendor/cordis/src/context.ts:12' }], +} + describe('Typert-backed Cordis catalog', () => { + it('omits subsystem source lines while preserving inherited Cordis source lines', () => { + const page = renderPageRegion('fixture.md', [{ + key: 'fixture', + type: 'Fixture', + abstract: false, + doc: 'Fixture.', + methods: [], + source: 'packages/fixture/service.ts:24', + }], [{ + name: 'fixture/ready', + scope: 'fixture', + signature: "'fixture/ready'(): void", + jsDoc: '/** Ready. */', + mode: 'emit', + doc: 'Ready.', + source: 'packages/fixture/events.ts:42', + }], SOURCE_LINK_POLICY) + const inherited = renderInheritedPage(SOURCE_LINK_POLICY) + + expect(page).toContain('Source: [`packages/fixture/events.ts`](../../packages/fixture/events.ts)') + expect(page).toContain('Source: [`packages/fixture/service.ts`](../../packages/fixture/service.ts)') + expect(inherited).toContain('([`vendor/cordis/src/events.ts:9`](../../vendor/cordis/src/events.ts))') + expect(inherited).toContain('([`vendor/cordis/src/context.ts:12`](../../vendor/cordis/src/context.ts))') + }) + it('reproduces every committed catalog artifact byte for byte', { timeout: 480_000 }, () => { const { projector, model } = projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY) const expected = (path: string): string => readFileSync(join(workspaceRoot, path), 'utf8') From 48d8e2f8f5870fa182db91a460cddacc6c0a0678 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 12:21:44 +0800 Subject: [PATCH 006/180] feat(agent-loop): finalize a cancelled stream's delivered prefix A turn cancelled mid-stream dropped everything the interrupted step had streamed: chunks stayed in the log for replay, but no assistant/message joined the surface, so the next request carried nothing the user had watched stream. Follow-ups after cancel could not connect and forks inherited the gap. Keep the streaming attempt alive across the step's request loop and, when an abort escapes with the attempt uncommitted, finalize its user-visible prefix as the step's ordinary assistant/message citing the logged chunk seqs. BlockAssembler.interruptedBlocks() owns the safe subset next to the max-tokens rule: closed and open text/reasoning blocks with content, in stream order; tool calls, empty blocks, and unknown open blocks drop. Retry clears the attempt first, so an abort after llm/retry finalizes nothing from the reset stream. Tool-phase cancellation and provider failures keep their existing shapes. --- ...cancelled-stream-prefix-finalize.i18n.yaml | 6 + ...-08-10-cancelled-stream-prefix-finalize.md | 37 ++++ ...-10-cancelled-stream-prefix-finalize.zh.md | 37 ++++ docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 21 ++- docs/persistence-catalog.zh.md | 9 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 9 + docs/subsystems/llm-streaming.zh.md | 9 + docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 5 +- docs/subsystems/session.zh.md | 5 +- .../goal-session/session.expected.jsonl | 7 +- .../goal-session/stdout.expected.jsonl | 1 + .../tests/snapshots/cancel/session.jsonl | 5 +- .../snapshots/cancel/stdout.expected.jsonl | 1 + packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 160 ++++++++++++------ packages/core/agent-loop/tests/cancel.spec.ts | 100 ++++++++++- .../core/agent-loop/tests/mock-adapter.ts | 20 ++- packages/core/session/src/types.ts | 5 +- packages/llm/llm/src/assembler.ts | 20 +++ packages/llm/llm/tests/assembler.spec.ts | 38 +++++ 25 files changed, 426 insertions(+), 89 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md create mode 100644 .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml new file mode 100644 index 0000000000..9c577a6c74 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.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-cancelled-stream-prefix-finalize.md +2026-08-10-cancelled-stream-prefix-finalize.md: a2201cd17fd89744590bd4024bff80f3e3835e39 +2026-08-10-cancelled-stream-prefix-finalize.zh.md: 08abb5c097bdf3c6c21e0a96873ab4a995e8f3e9 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md new file mode 100644 index 0000000000..a2201cd17f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md @@ -0,0 +1,37 @@ +# Agent Note: Cancelled streams finalize their delivered prefix + +Status: implemented + +English | [中文](2026-08-10-cancelled-stream-prefix-finalize.zh.md) + +## Problem + +A turn cancelled mid-stream used to drop everything the interrupted step had streamed: `assistant/chunk` events stayed in the log for replay, but no `assistant/message` joined the surface, so `deriveMessages()` carried nothing of the interrupted output into the next request. The user had watched the text stream and clients keep rendering it after the abort, yet from the model's perspective that text was never said. A follow-up like "expand on your second point" could not connect, and a fork taken at the cancelled turn inherited a surface missing content its owner had read. This divergence was never a weighed decision — the first agent-loop implementation checked the abort signal inside the chunk loop and threw before the finalize append, and the later surface whitelist froze that shape in. + +The governing principle this violated: what the user can see, the next model request contains. + +## Decision + +`Agent.step()` keeps the current streaming attempt (assembler, logged chunk seqs, provider route) alive across the request loop. When an abort escapes the step while an attempt is uncommitted, `appendInterruptedAssistant` finalizes the attempt's user-visible prefix as the step's ordinary `assistant/message` — `surfaceOp: 'append'`, `sourceEventSeqs` citing exactly the logged chunks — before the abort continues to the `step/end`/`turn/end` teardown. A retry decision clears the attempt first: `llm/retry` resets what clients render, so an abort after it finalizes nothing from the abandoned attempt. + +`BlockAssembler.interruptedBlocks()` owns what is safe to finalize, next to the existing max-tokens truncation rule: closed and open `text`/`reasoning` blocks with non-whitespace content, in stream order. Tool calls are dropped whole — interruption precedes dispatch, so a kept call would demand a fabricated result — as are empty blocks and open blocks of unknown type. When nothing survives, no event is appended and the turn keeps its previous shape: chunks, `step/end`, `turn/end` aborted. + +Cancellation during tool execution is untouched: the tool-call message was already finalized, started calls drain into real results, and undispatched calls keep their synthetic `ABORTED_BEFORE_DISPATCH` pairs. Provider failures (terminal error or aborted finishes) still commit nothing; only turn cancellation finalizes a prefix, because only there did the user watch content that would otherwise vanish from the model's history. + +## Alternatives considered + +**Keep dropping the prefix (status quo).** Safe and simple, but it makes cancel-then-redirect — a high-frequency flow — manufacture a user-visible/model-visible split on every use, and fork inherits the gap. Rejected: the split's cost recurs; the finalize cost is one-time. + +**Project the prefix at request time from the logged chunks.** No new surface event; `deriveMessages()` would assemble chunk prefixes for aborted steps. Rejected: it moves assembly policy into every surface consumer, breaks the "three message-producing event types" surface contract, and makes the derived history depend on non-surface events. + +**Finalize complete tool-call blocks too, with synthetic aborted results.** Preserves more of the model's intent. Rejected: the calls never dispatched and never rendered as tool cards, so parity does not ask for them, and fabricated result pairs add model-visible noise; the max-tokens rule already drops undispatchable calls. + +**Append an explicit interruption marker (`[interrupted by user]` user message).** What Claude Code does; tells the model its answer was cut off rather than complete. Deferred, not rejected: it is a separate model-visible vocabulary decision (source kind, UI rendering, locale strings) stacked on top of this parity fix, and the durable `turn/end aborted` already records the fact for a future projection to use. + +## Consequences + +The surface now contains what the user saw at the moment of cancellation, so post-cancel follow-ups and forks connect. The cancel and goal snapshot fixtures record the finalized prefix event, and the ACP bridge forwards it as a final `agent_message_chunk` update after the cancelled stop reason. An interrupted step's `assistant/message` can now carry a mid-sentence prefix; consumers reading the aborted `turn/end` can classify it. Terminal provider errors keep the old behavior — their streamed prefix still vanishes from the surface — an asymmetry deliberately left for a follow-up decision because error turns end without the user choosing to stop. + +## Testing + +`packages/core/agent-loop/tests/cancel.spec.ts` pins mid-stream finalize (content, cited seqs, event order, next-request parity), reasoning-only finalize, half-streamed tool-call dropping, and the nothing-to-finalize case. `packages/llm/llm/tests/assembler.spec.ts` pins `interruptedBlocks()`. The keyless `cancel` ACP snapshot and the goal-session snapshot carry the assembled-application transcript. diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md new file mode 100644 index 0000000000..08abb5c097 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 被取消的流定稿其已送达前缀 + +Status: implemented + +[English](2026-08-10-cancelled-stream-prefix-finalize.md) | 中文 + +## Problem + +轮次在流式输出中途被取消时,被打断的 step 已流出的内容过去会被整体丢弃:`assistant/chunk` 事件留在日志里供回放,但没有任何 `assistant/message` 进入 surface,`deriveMessages()` 不会把被打断的输出带进下一次请求。用户看着文字流出来,客户端在 abort 之后也继续显示它,但从模型的视角那段话从没说过。取消后追问「第二点展开讲讲」接不上,在被取消的轮次上 fork 出的分支继承的 surface 也缺少其主人读过的内容。这个分歧从来不是权衡后的决定:agent loop 的第一版实现就在分片循环里检查 abort 信号并在定稿 append 之前抛出,后来的 surface 白名单把这个形状固化了下来。 + +它违反的主导原则是:用户能看到什么,下一次模型请求就包含什么。 + +## Decision + +`Agent.step()` 让当前流式尝试(assembler、已记录的分片 seq、提供方路由)在请求循环之间保持存活。当 abort 在尝试未提交时逃出 step,`appendInterruptedAssistant` 会在 abort 继续走向 `step/end`/`turn/end` 收尾之前,把该尝试的用户可见前缀定稿为该 step 的普通 `assistant/message`,`surfaceOp: 'append'`,`sourceEventSeqs` 恰好引用已记录的分片。重试决定会先清空尝试:`llm/retry` 会重置客户端渲染的内容,因此其后的 abort 不会从被放弃的尝试中定稿任何东西。 + +`BlockAssembler.interruptedBlocks()` 拥有「什么可以安全定稿」的规则,与既有的 max-tokens 截断规则放在一起:按流顺序保留内容非空白的已闭合与未闭合 `text`/`reasoning` 块。工具调用整块丢弃,因为打断先于分派,保留的调用会要求捏造一个结果;空块和未知类型的未闭合块同样丢弃。没有内容存活时不追加任何事件,轮次保持原有形状:分片、`step/end`、`turn/end` aborted。 + +工具执行期间的取消不受影响:带工具调用的消息此前已定稿,已启动的调用排空为真实结果,未分派的调用保留合成的 `ABORTED_BEFORE_DISPATCH` 对。提供方故障(终局 error 或 aborted finish)仍然不提交任何内容;只有轮次取消定稿前缀,因为只有在那里用户看到过将从模型历史中消失的内容。 + +## Alternatives considered + +**继续丢弃前缀(维持现状)。** 安全且简单,但它让「取消然后转向」这个高频操作每次都制造一个用户可见与模型可见的分裂,fork 也继承这个缺口。否决:分裂的成本反复发生,定稿的成本只付一次。 + +**在请求时从已记录分片投影前缀。** 不加新 surface 事件,让 `deriveMessages()` 为 aborted step 装配分片前缀。否决:它把装配策略搬进每个 surface 消费者,破坏「三类产生消息的事件」的 surface 合同,并让派生历史依赖非 surface 事件。 + +**连完整的工具调用块也定稿,配合成的 aborted 结果。** 保留更多模型意图。否决:这些调用从未分派也从未渲染成工具卡片,对等原则并不要求它们,捏造的结果对还会增加模型可见的噪音;max-tokens 规则本来就丢弃不可分派的调用。 + +**追加显式打断标记(`[interrupted by user]` 用户消息)。** Claude Code 的做法,告诉模型回答是被切断的而不是完整的。搁置而非否决:它是叠加在本次对等修复之上的一个独立的模型可见词汇决定(source 种类、UI 渲染、locale 文案),而持久的 `turn/end aborted` 已经记录了这个事实,未来的投影可以使用。 + +## Consequences + +surface 现在包含取消瞬间用户看到的内容,取消后的追问和 fork 都能接上。cancel 与 goal 两组快照 fixture 记录了定稿前缀事件,ACP 桥在 cancelled stop reason 之后把它作为最后一条 `agent_message_chunk` 更新转发。被打断 step 的 `assistant/message` 现在可能带着一个中途截断的前缀;消费者读到 aborted 的 `turn/end` 即可归类。终局提供方错误保持旧行为,其已流出前缀仍会从 surface 消失,这个不对称是有意留给后续决定的,因为 error 轮次的结束不是用户主动选择的停止。 + +## Testing + +`packages/core/agent-loop/tests/cancel.spec.ts` 固定了流中取消的定稿(内容、引用的 seq、事件顺序、下一请求的对等)、仅 reasoning 的定稿、半流式工具调用的丢弃和无可定稿内容的情形。`packages/llm/llm/tests/assembler.spec.ts` 固定了 `interruptedBlocks()`。keyless 的 `cancel` ACP 快照和 goal-session 快照承载装配后应用的 transcript。 diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index e5dd4edde7..05813580cd 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: a17cae015eaa107a900069de916dddb216b87ec7 -persistence-catalog.zh.md: 3aef073dedcff0b6addb99d7c287f4e5f372c402 +persistence-catalog.md: 54598207c763a1801cd1f6b15d9887bf7875107f +persistence-catalog.zh.md: 7730f03ee220e43f21045ff9d0078241cd352845 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a17cae015e..54598207c7 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:318`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:379`](../packages/core/session/src/types.ts) ## Events @@ -185,14 +185,17 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/ * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event (undispatched tool calls are absent); an aborted turn with no such + * event streamed no visible content. */ 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } ``` Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts) ### `command/*` @@ -472,7 +475,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -484,7 +487,7 @@ Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -537,7 +540,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -612,7 +615,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) ### `tool/*` @@ -629,7 +632,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -698,7 +701,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) ### `turn/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 3aef073ded..7730f03ee2 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -81,7 +81,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:318`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:379`](../packages/core/session/src/types.ts) ## 事件 @@ -187,14 +187,17 @@ export type SessionEvent = { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event (undispatched tool calls are absent); an aborted turn with no such + * event streamed no visible content. */ 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } ``` 类型:[TokenUsage](subsystems/llm-streaming.md) -来源:[`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts) ### `command/*` diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 86ec4e3c3f..1024f88479 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: 4d450c19ec2bbfacabcefc83466e67c8a6c82bd6 -llm-streaming.zh.md: 777c44eaff2b1ee6e5939e04e580d6ad34b1ad1a +llm-streaming.md: 93669b0781a16121765ffafb094ea14c9021e1ec +llm-streaming.zh.md: bab98094f6a9ab3ca0aa9dc6178b46a612b205e8 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 4d450c19ec..93669b0781 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -289,6 +289,15 @@ declare class BlockAssembler { * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[]; + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with any streamed content, in stream order. + * Tool calls are dropped whole — interruption precedes dispatch, so a kept + * call would demand a fabricated result — as are empty text/reasoning blocks + * and open blocks of unknown type (there is nothing assembled to keep). + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[]; /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined; /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 777c44eaff..bab98094f6 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -297,6 +297,15 @@ declare class BlockAssembler { * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[]; + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with any streamed content, in stream order. + * Tool calls are dropped whole — interruption precedes dispatch, so a kept + * call would demand a fabricated result — as are empty text/reasoning blocks + * and open blocks of unknown type (there is nothing assembled to keep). + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[]; /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined; /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 3b9f9ce01d..c4dd54291f 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: f5b9e63e2320885cc41b30a09398dd341700152d -session.zh.md: 985e0a448d1cf860ccbb0f2885d855ad6830af9f +session.md: 521dab4e225b871d28ee870abfd3827c3fa999f3 +session.zh.md: 80dc4b4ec4dee11011480e8a28c8d4c0eae92adb diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index f5b9e63e23..521dab4e22 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -59,7 +59,10 @@ interface SessionEventMap { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event (undispatched tool calls are absent); an aborted turn with no such + * event streamed no visible content. */ 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } /** diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 985e0a448d..80dc4b4ec4 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -59,7 +59,10 @@ interface SessionEventMap { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event (undispatched tool calls are absent); an aborted turn with no such + * event streamed no visible content. */ 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } /** diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl index f7b0cc84b2..ad0c028dff 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -57,6 +57,7 @@ {"type":"user/message","seq":55,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"step/end","seq":58,"time":0,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":59,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} -{"type":"goal/change","seq":60,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}} +{"type":"assistant/message","seq":58,"time":0,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[56,57],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":0,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":60,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"goal/change","seq":61,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl index 9a54cb88c8..c0a4330ea9 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl @@ -3,3 +3,4 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL READY"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL ROUND ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index c6396be679..c46ccc543f 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -10,5 +10,6 @@ {"type":"request/context","seq":8,"time":1785730444532,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1785498791456,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1785730444541,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"step/end","seq":11,"time":1785730444547,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":1785730444547,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"assistant/message","seq":11,"time":1786334791338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"104e9294-f9b8-4248-b7df-0b7e2a069c0a"}},"sourceEventSeqs":[9,10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1786334791338,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":13,"time":1786334791338,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl index cb25d1c6bb..5958634036 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl @@ -1,3 +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","id":3,"result":{"stopReason":"cancelled"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 60ceaa7572..d6f0f5c62c 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. 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-loop/README.md -README.md: 6092363fae2853d6c5d92aaf8cd01e41e18e0b52 -README.zh.md: b65b5334d735a1e0b51fa517ce41c0c953f87cf7 +README.md: fe9f58b8fa9a3851a518300b31c161b45780b80a +README.zh.md: 62f4f63c092251e69d5788275475164fd83c8100 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6092363fae..fe9f58b8fa 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -63,7 +63,7 @@ Every inbox mutation publishes one normalized `agent/inbox/spliced` event before The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Package-private orchestration entry points recover the exact Agent, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or per-operation `Session` through shallow interfaces. A helper keeps an explicit `Session` when that is its actual interface, while creation, persistence load, unpublished setup, services, workers, processes, persistence, and wire protocols retain their explicit identities. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules. -Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, lists the exact chunk seqs in `sourceEventSeqs` (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. +Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, lists the exact chunk seqs in `sourceEventSeqs` (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. A turn cancellation that interrupts streaming also appends one anchor when visible content already streamed: the logged chunks reached the user, so the closed and open text/reasoning blocks they assemble join the surface, citing those chunk seqs — the next request contains what the user saw. Tool calls are dropped whole (interruption precedes dispatch), and nothing is appended when only empty or undispatchable content streamed; provider failures still commit nothing ([decision](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md)). After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance follows the same adapter-default marker rule when resuming. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index b65b5334d7..62f4f63c09 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -63,7 +63,7 @@ interface Config { 驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。包私有的编排入口点会恢复确切的 Agent,一次性派生 `agent.session`,并让操作局部的辅助函数捕获它,而不是通过浅层接口继续传递具体驱动器或每次操作的 `Session`。如果显式 `Session` 正是辅助函数的实际接口,该辅助函数会保留它;创建、持久化加载、未发布 setup、服务、worker、进程、持久化和 wire 协议则继续保留各自的显式身份。[agent 服务](../agent/README.md#initiating-agent-scope)规定传播、teardown 和分离工作规则。 -每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,在 `sourceEventSeqs` 中列出确切的分片 seq(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。 +每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,在 `sourceEventSeqs` 中列出确切的分片 seq(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。打断流式输出的轮次取消在已流出可见内容时同样追加一个锚点:已记录的分片已经送达用户,因此由它们组装出的已闭合与未闭合 text/reasoning 块进入 surface,并引用这些分片 seq,因此下一次请求包含用户看到的内容。工具调用整块丢弃(打断先于分派),只流出空内容或不可分派内容时不追加任何事件;提供方故障仍然不提交任何内容([决策](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md))。 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会遵循同一套适配器默认值标记规则。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6ef965e59e..c3fb9737b8 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -51,6 +51,14 @@ type PreparedStep = | { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly } +/** One live streaming attempt whose logged chunk prefix an abort can still finalize. */ +interface InterruptedAttempt { + readonly assembler: BlockAssembler + readonly chunkSeqs: number[] + readonly provider: string + readonly model: string +} + /** Remove adapter-derived values before plugins propose the next request config. */ function requestProposal(header: EpochHeader): LlmCallConfig { if (header.adapterDefaults === undefined) return header.config @@ -336,70 +344,112 @@ export class ReactLoopAgent implements Agent { signal.throwIfAborted() const system = renderPrompt(assembly) - while (true) { - const { request, preparedCall } = await this.buildRequest( - turn, step, assembly.tools, system, this.session.deriveMessages(), signal, - ) - const assembler = new BlockAssembler() - const chunkSeqs: number[] = [] - const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) - signal.throwIfAborted() - for await (const chunk of stream) { + // The streaming attempt an abort may still finalize: chunks already logged + // reached the user, so cancellation commits their assemblable prefix to the + // surface instead of dropping it (see appendInterruptedAssistant). Cleared + // once the attempt commits normally or a retry resets the visible stream. + let attempt: InterruptedAttempt | undefined + try { + while (true) { + const { request, preparedCall } = await this.buildRequest( + turn, step, assembly.tools, system, this.session.deriveMessages(), signal, + ) + const assembler = new BlockAssembler() + const chunkSeqs: number[] = [] + attempt = { assembler, chunkSeqs, provider: request.provider, model: request.model } + const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) signal.throwIfAborted() - chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq) - assembler.push(chunk) - } - signal.throwIfAborted() - const finish = assembler.finish - if (finish.kind === 'error' || finish.kind === 'aborted') { - const action = await this.dispatch.waterfall( - 'agent/request-error', { + for await (const chunk of stream) { + signal.throwIfAborted() + chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq) + assembler.push(chunk) + } + signal.throwIfAborted() + const finish = assembler.finish + if (finish.kind === 'error' || finish.kind === 'aborted') { + const action = await this.dispatch.waterfall( + 'agent/request-error', { + turn, + step, + provider: request.provider, + failure: finish.failure, + retryPolicy: preparedCall?.retryPolicy, + signal, + }, + () => Promise.resolve(undefined), + ) + signal.throwIfAborted() + if (action?.kind !== 'retry') { + throw new LlmError(finish.failure.message, finish.failure.code, finish.failure) + } + attempt = undefined + continue + } + + const message = createAssistantMessage({ + content: assembler.blocks(), + source: { + provider: request.provider, + model: request.model, + ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, + }, + }) + attempt = undefined + this.session.append( + 'assistant/message', + { turn, step, - provider: request.provider, - failure: finish.failure, - retryPolicy: preparedCall?.retryPolicy, - signal, + message, + ...assembler.usage === undefined ? {} : { usage: assembler.usage }, }, - () => Promise.resolve(undefined), + { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, ) - signal.throwIfAborted() - if (action?.kind !== 'retry') { - throw new LlmError(finish.failure.message, finish.failure.code, finish.failure) - } - continue + if (finish.kind === 'max-tokens') return { kind: 'max-tokens' } + + const toolCalls = message.content.filter(block => block.type === 'tool-call') + if (toolCalls.length === 0) return { kind: 'completed' } + const { concluded } = await executeToolCalls( + this.loopCtx, turn, step, toolCalls, signal, + context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]), + ) + return concluded ? { kind: 'completed' } : null } - - const message = createAssistantMessage({ - content: assembler.blocks(), - source: { - provider: request.provider, - model: request.model, - ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, - }, - }) - this.session.append( - 'assistant/message', - { - turn, - step, - message, - ...assembler.usage === undefined ? {} : { usage: assembler.usage }, - }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, - ) - if (finish.kind === 'max-tokens') return { kind: 'max-tokens' } - - const toolCalls = message.content.filter(block => block.type === 'tool-call') - if (toolCalls.length === 0) return { kind: 'completed' } - const { concluded } = await executeToolCalls( - this.loopCtx, turn, step, toolCalls, signal, - context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]), - ) - return concluded ? { kind: 'completed' } : null + } catch (error: unknown) { + if (signal.aborted && attempt !== undefined) { + this.appendInterruptedAssistant(turn, step, attempt) + } + throw error } } + /** + * Finalize a cancelled streaming attempt's user-visible prefix onto the + * surface: everything already logged as `assistant/chunk` events was + * delivered to the user, and the next request must contain what the user saw. + * Keeps the assembler's interrupted-safe blocks (text/reasoning; tool calls + * were never dispatched and are dropped); appends nothing when no visible + * content streamed before the interruption. + */ + private appendInterruptedAssistant(turn: number, step: number, attempt: InterruptedAttempt): void { + const content = attempt.assembler.interruptedBlocks() + if (content.length === 0) return + const message = createAssistantMessage({ + content, + source: { provider: attempt.provider, model: attempt.model }, + }) + this.session.append( + 'assistant/message', + { + turn, + step, + message, + ...attempt.assembler.usage === undefined ? {} : { usage: attempt.assembler.usage }, + }, + { surfaceOp: 'append', sourceEventSeqs: attempt.chunkSeqs }, + ) + } + /** * Compose one frozen request and bind it to the adapter registration that * resolved its exact-model defaults. diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 6fb77d141c..f8ecc4680f 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,4 +1,4 @@ -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' /** * Tests for the queue-aware `Agent.cancel()` primitive. The default clears * queued and steering work, while `keepInbox` preserves pending input for a @@ -479,6 +479,104 @@ describe('Agent.cancel()', () => { expect(reasons.length).toBe(2) }) + it('cancel mid-stream finalizes the streamed prefix onto the surface', async () => { + const adapter = new MockAdapter(['hang', textResponse('after')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('partial-finalize'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + // The prefix the user watched stream is committed as the step's message, + // citing exactly the chunk events that delivered it. + const message = agent.session.events.find(e => e.type === 'assistant/message') + expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'text', text: 'partial' }]) + const chunkSeqs = agent.session.events.filter(e => e.type === 'assistant/chunk').map(e => e.seq) + expect(message?.sourceEventSeqs).toEqual(chunkSeqs) + const types = agent.session.events.map(e => e.type) + expect(types.indexOf('assistant/message')).toBeLessThan(types.indexOf('step/end')) + expect(types.indexOf('step/end')).toBeLessThan(types.indexOf('turn/end')) + + // The next request derives the finalized prefix: the model sees what the user saw. + send(agent, 'continue') + await waitForIdle(ctx, agent) + const replayed = adapter.requests[1]!.messages + .filter(m => m.role === 'assistant') + .flatMap(m => m.content) + .flatMap(b => b.type === 'text' ? [b.text] : []) + expect(replayed).toContain('partial') + }) + + it('cancel during reasoning-only streaming finalizes the reasoning prefix', async () => { + const adapter = new MockAdapter([{ + hangAfter: [ + { type: 'block-start', index: 0, blockType: 'reasoning' }, + { type: 'reasoning-delta', index: 0, text: 'thinking about it' }, + { type: 'usage', usage: { inputTokens: 7, outputTokens: 4 } }, + ], + }]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('reasoning-finalize'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + const message = agent.session.events.find(e => e.type === 'assistant/message') + expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'reasoning', text: 'thinking about it' }]) + // A usage chunk delivered before the cancel travels with the finalized prefix. + expect(message?.type === 'assistant/message' ? message.data.usage : undefined) + .toEqual({ inputTokens: 7, outputTokens: 4 }) + }) + + it('cancel drops a half-streamed tool call and keeps the completed text before it', async () => { + const adapter = new MockAdapter([{ + hangAfter: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'reading the file' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'reading the file' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read', argumentsDelta: '{"pa' }, + ], + }]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('tool-call-drop'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + // The undispatched call is dropped whole — no dangling tool_use to pair. + const message = agent.session.events.find(e => e.type === 'assistant/message') + expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'text', text: 'reading the file' }]) + expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + }) + + it('cancel before any visible content finalizes nothing', async () => { + const adapter = new MockAdapter([{ + hangAfter: [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'read', argumentsDelta: '{"pa' }, + ], + }]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('nothing-to-finalize'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + }) + it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index 69dc78990c..c5341fb7ed 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -55,19 +55,25 @@ export function toolCallResponse(rawCallId: string, name: string, args: object, return chunks } +/** Script entry that streams the given chunks, then hangs until aborted. */ +export interface HangAfter { + hangAfter: StreamChunk[] +} + /** * Mock adapter driven by a script: each model call consumes the next entry. * Records every request it receives for assertions. An entry may be a * function to compute chunks from the request, a 'hang' marker that - * streams one chunk then waits until aborted, or 'hang-slow' which takes + * streams one chunk then waits until aborted, 'hang-slow' which takes * 50ms to notice the abort — a stand-in for slow real-world teardown - * (LLM stream cancellation, tool unwinding). + * (LLM stream cancellation, tool unwinding) — or a {@link HangAfter} + * scripting the exact chunks delivered before the hang. */ export class MockAdapter extends LlmAdapter { requests: GenerateOptions[] = [] constructor( - private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow')[], + private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow' | HangAfter)[], private readonly reasoning?: LlmModelReasoningInfo, private readonly defaultMaxTokens?: number, ) { @@ -100,6 +106,14 @@ export class MockAdapter extends LlmAdapter { }) return } + if (typeof entry === 'object' && !Array.isArray(entry) && 'hangAfter' in entry) { + for (const chunk of entry.hangAfter) yield chunk + await new Promise((_resolve, reject) => { + if (options.signal?.aborted) { reject(new Error('aborted')); return } + options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + return + } if (entry === 'hang-slow') { yield { type: 'block-start', index: 0, blockType: 'text' } yield { type: 'text-delta', index: 0, text: 'partial' } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 6074b51c02..e0d3a9b196 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -240,7 +240,10 @@ export interface SessionEventMap { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event (undispatched tool calls are absent); an aborted turn with no such + * event streamed no visible content. */ 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } /** diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index a0e1332417..d65a1bc796 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -138,6 +138,26 @@ export class BlockAssembler { : blocks } + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with any streamed content, in stream order. + * Tool calls are dropped whole — interruption precedes dispatch, so a kept + * call would demand a fabricated result — as are empty text/reasoning blocks + * and open blocks of unknown type (there is nothing assembled to keep). + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[] { + return this.order + .map((index) => { + const partial = this.mustGet(index) + const type = partial.block?.type ?? partial.blockType + if (type !== 'text' && type !== 'reasoning') return undefined + return this.assemble(partial, index) + }) + .filter((block): block is ContentBlock => + (block?.type === 'text' || block?.type === 'reasoning') && block.text.trim() !== '') + } + /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined { return this._usage diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index bf2276a218..a121d303b6 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -145,3 +145,41 @@ describe('BlockAssembler duplicate-close contract', () => { expect(assembler.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) }) }) + +describe('BlockAssembler.interruptedBlocks', () => { + it('keeps closed and open text/reasoning blocks with streamed content, in order', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'reasoning' }) + assembler.push({ type: 'reasoning-delta', index: 0, text: 'planning' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'planning' } }) + assembler.push({ type: 'block-start', index: 1, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 1, text: 'half an ans' }) + expect(assembler.interruptedBlocks()).toEqual([ + { type: 'reasoning', text: 'planning' }, + { type: 'text', text: 'half an ans' }, + ]) + }) + + it('drops tool calls whether open or closed — interruption precedes dispatch', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: 'calling' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'calling' } }) + assembler.push({ type: 'block-start', index: 1, blockType: 'tool-call' }) + assembler.push({ type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read', argumentsDelta: '{"a":1}' }) + assembler.push({ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"a":1}' } }) + assembler.push({ type: 'block-start', index: 2, blockType: 'tool-call' }) + assembler.push({ type: 'tool-call-delta', index: 2, id: CallId('c2'), name: 'read', argumentsDelta: '{"pa' }) + expect(assembler.interruptedBlocks()).toEqual([{ type: 'text', text: 'calling' }]) + }) + + it('drops empty and whitespace-only text/reasoning blocks and unknown open block types', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: ' \n' }) + // A merge-extended block kind this build does not know how to assemble. + assembler.push({ type: 'block-start', index: 1, blockType: 'mystery' } as unknown as StreamChunk) + assembler.push({ type: 'block-start', index: 2, blockType: 'reasoning' }) + expect(assembler.interruptedBlocks()).toEqual([]) + }) +}) From 87f24bb9917a9f989ce217d51eb29b3b48eb9fca Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 13:10:43 +0800 Subject: [PATCH 007/180] fix(agent-loop): mark finalized prefixes durable-interrupted; never finalize failed attempts Review round findings: - Clear the streaming attempt before the request-error waterfall: a cancel landing during recovery (typically the llm/retry backoff, after clients reset the streamed rendering) must not resurrect the failed stream's prefix. Provider failures commit nothing, now including that window. - Record interrupted: true on the cancellation-finalized assistant/message. The chat projection keeps the settled prefix classified as interrupted (Stopped chip, restored web goldens), and request inspection leaves the request uncompleted so the step boundary classifies it as before. - Pin the recovery-window and retry-discard semantics with content-bearing failed streams in cancel.spec; update the ACP late-end expectation to the finalized-prefix transcript. - Mention interruptedBlocks() in the assembler stream-lifecycle JSDoc. --- ...cancelled-stream-prefix-finalize.i18n.yaml | 4 +- ...-08-10-cancelled-stream-prefix-finalize.md | 4 +- ...-10-cancelled-stream-prefix-finalize.zh.md | 4 +- .../live-interactions/cancel.expected.md | 4 +- .../queue-actions/preserved.expected.md | 4 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 23 ++++---- docs/persistence-catalog.zh.md | 23 ++++---- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 3 +- docs/subsystems/llm-streaming.zh.md | 3 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 7 ++- docs/subsystems/session.zh.md | 7 ++- .../goal-session/session.expected.jsonl | 2 +- .../tests/snapshots/cancel/session.jsonl | 2 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../advanced-toolchain/session.1.jsonl | 8 +-- .../advanced-toolchain/session.2.jsonl | 8 +-- .../advanced-toolchain/session.jsonl | 28 ++++----- .../tests/snapshots/pty-tools/session.jsonl | 34 +++++------ packages/acp/acp/tests/turns.spec.ts | 4 +- .../src/client/sessions/request-inspection.ts | 5 +- .../runtime/tests/request-inspection.spec.ts | 25 ++++++++ .../client/conversation-nodes/assistant.ts | 4 ++ .../conversation-node-definitions.spec.ts | 16 +++++ packages/core/agent-loop/src/agent.ts | 7 ++- packages/core/agent-loop/tests/cancel.spec.ts | 59 ++++++++++++++++++- packages/core/session/src/types.ts | 7 ++- packages/llm/llm/src/assembler.ts | 3 +- .../tool-cordis/src/api-catalog.ts | 2 +- 31 files changed, 217 insertions(+), 97 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml index 9c577a6c74..21ba2beb71 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages 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-cancelled-stream-prefix-finalize.md -2026-08-10-cancelled-stream-prefix-finalize.md: a2201cd17fd89744590bd4024bff80f3e3835e39 -2026-08-10-cancelled-stream-prefix-finalize.zh.md: 08abb5c097bdf3c6c21e0a96873ab4a995e8f3e9 +2026-08-10-cancelled-stream-prefix-finalize.md: 34b68ff30e399d75b2ad4417bd3e2b8c25cd5757 +2026-08-10-cancelled-stream-prefix-finalize.zh.md: febd96f8179433fb7411eed6c533e95bcfad2648 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md index a2201cd17f..34b68ff30e 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md @@ -12,7 +12,7 @@ The governing principle this violated: what the user can see, the next model req ## Decision -`Agent.step()` keeps the current streaming attempt (assembler, logged chunk seqs, provider route) alive across the request loop. When an abort escapes the step while an attempt is uncommitted, `appendInterruptedAssistant` finalizes the attempt's user-visible prefix as the step's ordinary `assistant/message` — `surfaceOp: 'append'`, `sourceEventSeqs` citing exactly the logged chunks — before the abort continues to the `step/end`/`turn/end` teardown. A retry decision clears the attempt first: `llm/retry` resets what clients render, so an abort after it finalizes nothing from the abandoned attempt. +`Agent.step()` keeps the current streaming attempt (assembler, logged chunk seqs, provider route) alive across the request loop. When an abort escapes the step while an attempt is uncommitted, `appendInterruptedAssistant` finalizes the attempt's user-visible prefix as the step's `assistant/message` with `interrupted: true` — `surfaceOp: 'append'`, `sourceEventSeqs` citing exactly the logged chunks — before the abort continues to the `step/end`/`turn/end` teardown. The durable marker is the classification consumers read: the chat projection keeps rendering the settled prefix as interrupted (the Stopped chip), and request inspection leaves the request uncompleted so the step boundary classifies it as before. An attempt that ends in an `error`/`aborted` finish is cleared before the recovery waterfall runs: provider failures commit nothing, and a cancel landing during recovery (typically the `llm/retry` backoff, after clients reset the streamed rendering) must not resurrect the failed stream's prefix. `BlockAssembler.interruptedBlocks()` owns what is safe to finalize, next to the existing max-tokens truncation rule: closed and open `text`/`reasoning` blocks with non-whitespace content, in stream order. Tool calls are dropped whole — interruption precedes dispatch, so a kept call would demand a fabricated result — as are empty blocks and open blocks of unknown type. When nothing survives, no event is appended and the turn keeps its previous shape: chunks, `step/end`, `turn/end` aborted. @@ -30,7 +30,7 @@ Cancellation during tool execution is untouched: the tool-call message was alrea ## Consequences -The surface now contains what the user saw at the moment of cancellation, so post-cancel follow-ups and forks connect. The cancel and goal snapshot fixtures record the finalized prefix event, and the ACP bridge forwards it as a final `agent_message_chunk` update after the cancelled stop reason. An interrupted step's `assistant/message` can now carry a mid-sentence prefix; consumers reading the aborted `turn/end` can classify it. Terminal provider errors keep the old behavior — their streamed prefix still vanishes from the surface — an asymmetry deliberately left for a follow-up decision because error turns end without the user choosing to stop. +The surface now contains what the user saw at the moment of cancellation, so post-cancel follow-ups and forks connect. The cancel and goal snapshot fixtures record the finalized prefix event, and the ACP bridge forwards it as a final `agent_message_chunk` update after the cancelled stop reason — prompt settlement does not wait on loop teardown, so automation clients may receive the update after the cancelled stop reason. An interrupted step's `assistant/message` carries a mid-sentence prefix and the `interrupted: true` marker that classifies it. Terminal provider errors keep the old behavior — their streamed prefix still vanishes from the surface — an asymmetry deliberately left for a follow-up decision because error turns end without the user choosing to stop. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md index 08abb5c097..febd96f817 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md @@ -12,7 +12,7 @@ Status: implemented ## Decision -`Agent.step()` 让当前流式尝试(assembler、已记录的分片 seq、提供方路由)在请求循环之间保持存活。当 abort 在尝试未提交时逃出 step,`appendInterruptedAssistant` 会在 abort 继续走向 `step/end`/`turn/end` 收尾之前,把该尝试的用户可见前缀定稿为该 step 的普通 `assistant/message`,`surfaceOp: 'append'`,`sourceEventSeqs` 恰好引用已记录的分片。重试决定会先清空尝试:`llm/retry` 会重置客户端渲染的内容,因此其后的 abort 不会从被放弃的尝试中定稿任何东西。 +`Agent.step()` 让当前流式尝试(assembler、已记录的分片 seq、提供方路由)在请求循环之间保持存活。当 abort 在尝试未提交时逃出 step,`appendInterruptedAssistant` 会在 abort 继续走向 `step/end`/`turn/end` 收尾之前,把该尝试的用户可见前缀定稿为该 step 的带 `interrupted: true` 的 `assistant/message`,`surfaceOp: 'append'`,`sourceEventSeqs` 恰好引用已记录的分片。这个持久标记就是消费者读取的分类:chat 投影继续把定稿前缀渲染为被打断(Stopped 徽章),请求检查让该请求保持未完成,由 step 边界照旧归类。以 `error`/`aborted` finish 结束的尝试会在恢复 waterfall 运行前被清空:提供方故障不提交任何内容,落在恢复期间的取消(典型是 `llm/retry` 退避期,此时客户端已重置流式渲染)不得复活失败流的前缀。 `BlockAssembler.interruptedBlocks()` 拥有「什么可以安全定稿」的规则,与既有的 max-tokens 截断规则放在一起:按流顺序保留内容非空白的已闭合与未闭合 `text`/`reasoning` 块。工具调用整块丢弃,因为打断先于分派,保留的调用会要求捏造一个结果;空块和未知类型的未闭合块同样丢弃。没有内容存活时不追加任何事件,轮次保持原有形状:分片、`step/end`、`turn/end` aborted。 @@ -30,7 +30,7 @@ Status: implemented ## Consequences -surface 现在包含取消瞬间用户看到的内容,取消后的追问和 fork 都能接上。cancel 与 goal 两组快照 fixture 记录了定稿前缀事件,ACP 桥在 cancelled stop reason 之后把它作为最后一条 `agent_message_chunk` 更新转发。被打断 step 的 `assistant/message` 现在可能带着一个中途截断的前缀;消费者读到 aborted 的 `turn/end` 即可归类。终局提供方错误保持旧行为,其已流出前缀仍会从 surface 消失,这个不对称是有意留给后续决定的,因为 error 轮次的结束不是用户主动选择的停止。 +surface 现在包含取消瞬间用户看到的内容,取消后的追问和 fork 都能接上。cancel 与 goal 两组快照 fixture 记录了定稿前缀事件,ACP 桥在 cancelled stop reason 之后把它作为最后一条 `agent_message_chunk` 更新转发,prompt 的结算不等待循环收尾,因此自动化客户端可能在 cancelled stop reason 之后才收到该更新。被打断 step 的 `assistant/message` 带着中途截断的前缀和用于归类的 `interrupted: true` 标记。终局提供方错误保持旧行为,其已流出前缀仍会从 surface 消失,这个不对称是有意留给后续决定的,因为 error 轮次的结束不是用户主动选择的停止。 ## Testing diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 1d87f01525..bb290942b3 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -19,7 +19,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} - textbox "Message the agent" - button "Commands": - img @@ -28,4 +28,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index a845590873..ac1740d0c8 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -19,7 +19,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} - button "2 queued messages" [expanded] - list: - listitem: @@ -47,4 +47,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} Input 0 tok · Output 0 tok diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index d427910f80..17187309ad 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: 96374f4488dbfd4e60da99532ae2e559a3855c0f -persistence-catalog.zh.md: c39e5db3deb01305ae687b79e2ee252f29bca55d +persistence-catalog.md: 09f440514335fdc24b9abd036c448818aa8a4465 +persistence-catalog.zh.md: 6fe6b46375d30505fcea742c31d5cf6701491c95 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 96374f4488..09f4405143 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:387`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:388`](../packages/core/session/src/types.ts) ## Events @@ -203,15 +203,16 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ * the model output and its accounting travel together (there is no separate * usage record). `usage` is absent when the adapter reported none. A turn * cancelled mid-stream finalizes its delivered text/reasoning prefix as this - * event (undispatched tool calls are absent); an aborted turn with no such - * event streamed no visible content. + * event with `interrupted: true` (undispatched tool calls are absent), so + * consumers classify the truncation without re-deriving it from turn + * boundaries; an aborted turn with no such event streamed no visible content. */ -'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } +'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } ``` Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) ### `command/*` @@ -491,7 +492,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:293`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -503,7 +504,7 @@ Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -556,7 +557,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -631,7 +632,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) ### `tool/*` @@ -648,7 +649,7 @@ Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -717,7 +718,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) ### `turn/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c39e5db3de..6fe6b46375 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -81,7 +81,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:387`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:388`](../packages/core/session/src/types.ts) ## 事件 @@ -205,15 +205,16 @@ export type SessionEvent = { * the model output and its accounting travel together (there is no separate * usage record). `usage` is absent when the adapter reported none. A turn * cancelled mid-stream finalizes its delivered text/reasoning prefix as this - * event (undispatched tool calls are absent); an aborted turn with no such - * event streamed no visible content. + * event with `interrupted: true` (undispatched tool calls are absent), so + * consumers classify the truncation without re-deriving it from turn + * boundaries; an aborted turn with no such event streamed no visible content. */ -'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } +'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } ``` 类型:[TokenUsage](subsystems/llm-streaming.md) -来源:[`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) ### `command/*` @@ -493,7 +494,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:293`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -505,7 +506,7 @@ export type SessionEvent = { 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -来源:[`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -558,7 +559,7 @@ export type SessionEvent = { 'session/end-seed': Record ``` -来源:[`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -633,7 +634,7 @@ export type SessionEvent = { 类型:[TodoItem](subsystems/session.md) -来源:[`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) ### `tool/*` @@ -650,7 +651,7 @@ export type SessionEvent = { 类型:[CallId](subsystems/core.md) -来源:[`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -719,7 +720,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) ### `turn/*` diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 1024f88479..032d37ef30 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: 93669b0781a16121765ffafb094ea14c9021e1ec -llm-streaming.zh.md: bab98094f6a9ab3ca0aa9dc6178b46a612b205e8 +llm-streaming.md: 7ab12d8e26b5bedbde8a20e661b049336ed2a459 +llm-streaming.zh.md: f0fe8c1e724f7e58251a772da5c5fc9a1ff3d9e9 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 93669b0781..7ab12d8e26 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -270,7 +270,8 @@ interface TokenUsage { * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index bab98094f6..f0fe8c1e72 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -278,7 +278,8 @@ interface TokenUsage { * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 4ee96ac032..36ce517713 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: 3ffd7997afaf93633a034ffb8ebd308bd18e773d -session.zh.md: 9a89f42c5a7f8bf0ac82ab0d9ca96be506d18876 +session.md: 7c26bbc1cfd197adf139c353c610ce2d951f0170 +session.zh.md: b05bbd52eec45fbd5520f1b6cc5e343b8e80950b diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 3ffd7997af..7c26bbc1cf 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -61,10 +61,11 @@ interface SessionEventMap { * the model output and its accounting travel together (there is no separate * usage record). `usage` is absent when the adapter reported none. A turn * cancelled mid-stream finalizes its delivered text/reasoning prefix as this - * event (undispatched tool calls are absent); an aborted turn with no such - * event streamed no visible content. + * event with `interrupted: true` (undispatched tool calls are absent), so + * consumers classify the truncation without re-deriving it from turn + * boundaries; an aborted turn with no such event streamed no visible content. */ - 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } /** * The model requested one tool invocation: `name` with the raw `arguments` * JSON string exactly as the model produced it (unparsed). `callId` pairs the diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 9a89f42c5a..b05bbd52ee 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -61,10 +61,11 @@ interface SessionEventMap { * the model output and its accounting travel together (there is no separate * usage record). `usage` is absent when the adapter reported none. A turn * cancelled mid-stream finalizes its delivered text/reasoning prefix as this - * event (undispatched tool calls are absent); an aborted turn with no such - * event streamed no visible content. + * event with `interrupted: true` (undispatched tool calls are absent), so + * consumers classify the truncation without re-deriving it from turn + * boundaries; an aborted turn with no such event streamed no visible content. */ - 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } /** * The model requested one tool invocation: `name` with the raw `arguments` * JSON string exactly as the model produced it (unparsed). `callId` pairs the diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl index ad0c028dff..ab905a7235 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -57,7 +57,7 @@ {"type":"user/message","seq":55,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"assistant/message","seq":58,"time":0,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[56,57],"surfaceOp":"append"} +{"type":"assistant/message","seq":58,"time":0,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true},"sourceEventSeqs":[56,57],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":0,"data":{"turn":3,"step":1}} {"type":"turn/end","seq":60,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} {"type":"goal/change","seq":61,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index c46ccc543f..af088a8ff7 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -10,6 +10,6 @@ {"type":"request/context","seq":8,"time":1785730444532,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1785498791456,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1785730444541,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"assistant/message","seq":11,"time":1786334791338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"104e9294-f9b8-4248-b7df-0b7e2a069c0a"}},"sourceEventSeqs":[9,10],"surfaceOp":"append"} +{"type":"assistant/message","seq":11,"time":1786334791338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"104e9294-f9b8-4248-b7df-0b7e2a069c0a"},"interrupted":true},"sourceEventSeqs":[9,10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1786334791338,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":13,"time":1786334791338,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} 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 6d339a4512..99e513903b 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type 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 readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"2ec5ca51-ec8b-4756-8c71-c20fb871b421"}},"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\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 interrupted?: true;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"63624313-7ef7-4c74-af4a-a96dbc850d79"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 44cbf0e360..c4d8d2109a 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":"adc87967-3604-494d-9a0c-d3ba992321b5"}]}} {"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":"adc87967-3604-494d-9a0c-d3ba992321b5"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730501506,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":7,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":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":"5a0d8fae-1484-40f9-8fbc-a21bd6e34f4b"},"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..57af223034 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":"80153740-f1c2-4c68-a021-f683b7299f9a"}]}} {"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":"80153740-f1c2-4c68-a021-f683b7299f9a"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730501645,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":7,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":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":"c0e12068-d11c-470b-b579-d3e1f965d3dc"},"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..eb7ab599fb 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":"660f770b-8f37-4cb9-8bfb-7e588e297ad7"}]}} {"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":"660f770b-8f37-4cb9-8bfb-7e588e297ad7"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e65c0ebe-8e3d-44c0-833f-68efcbc0acb5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"34d67879-3a88-4396-a1b0-127bae0e1ccf"},"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":"157dc2aa-743a-4564-b1d3-472c25bf5831"}},"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":"9d5b359b-f2e6-4648-aa0c-89fac1938f30"},"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":"f8b90fd0-fabb-4e8d-8645-b10f7674c998"}},"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":"eb0416ad-752f-4c9a-a6c0-066e955c36d6"},"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":"d14db6df-b6fa-4cd4-aa8e-701ebe0bcab7"}},"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":"69a4bcfc-a8af-41e5-827a-704a9ac4a84f"},"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":"5a027b96-5860-408c-86d3-d7c1306b4640"}},"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":"f552f360-52c4-4d68-96c2-e86b9cab792e"},"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":"cc001237-dcec-42bd-a728-c580d3028e17"}},"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":"acc0c8e0-97ea-4225-a735-9a54193c20d8"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} {"type":"step/end","seq":66,"time":1785730501679,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":67,"time":1785730501679,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index f4f592570a..d94f0009aa 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -1,21 +1,21 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498587408,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"39f39ecc-5772-4814-8feb-46433c71becd"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498587408,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d05853ba-1ee0-477e-b673-92a955b98724"}]}} {"type":"turn/start","seq":1,"time":1785821457966,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821457966,"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":4,"time":1785498587436,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"39f39ecc-5772-4814-8feb-46433c71becd"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730504659,"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."}],"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."}]},"role":"user","id":"a5ae9c04-0652-436f-9b5a-437a3a6ed235"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498587436,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d05853ba-1ee0-477e-b673-92a955b98724"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730504659,"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."}],"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."}]},"role":"user","id":"d4ac87a7-eed3-41fd-bf5d-e6d49f331f66"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730504659,"data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498587438,"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.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\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\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\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.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"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":"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":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":7,"time":1785498587438,"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.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\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\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\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.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"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":"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":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730504660,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} {"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} {"type":"assistant/chunk","seq":12,"time":1785498587439,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":13,"time":1785730504661,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1785730504661,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8cad9650-de5a-4075-8aa3-1b35e67efc2d"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730504661,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"898077a7-5a40-4517-9eeb-bd840750776b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730504662,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"type":"tool/result","seq":16,"time":1785730504671,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"1ba1f641-0cf2-496c-895e-3982aa40b0ed"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730504671,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"ea4323d9-aa22-4a9c-a7cc-165c147ce624"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730504671,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730504679,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -23,9 +23,9 @@ {"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}} {"type":"assistant/chunk","seq":22,"time":1785498587457,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":23,"time":1785730504680,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":24,"time":1785730504680,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f5850535-906d-4a78-8518-a733ec91bbd8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","seq":24,"time":1785730504680,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"dc612d66-912d-4248-b95c-48675b127574"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1785730504680,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","seq":26,"time":1785730504688,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"4f19b0d0-b31d-40b7-84e5-2783131cb363"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","seq":26,"time":1785730504688,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"fbcfc85b-a9fb-4421-81ec-f16f8ba772c4"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":27,"time":1785730504688,"data":{"turn":1,"step":2}} {"type":"step/start","seq":28,"time":1785730504696,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -33,9 +33,9 @@ {"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}} {"type":"assistant/chunk","seq":32,"time":1785498587473,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":33,"time":1785730504697,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":34,"time":1785730504697,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4e0d8eed-877d-4a5b-bd92-e7c4c8f2cf23"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"assistant/message","seq":34,"time":1785730504697,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c023922c-a5a6-4dd4-bace-578f30e453f5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} {"type":"tool/call","seq":35,"time":1785730504697,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} -{"type":"tool/result","seq":36,"time":1785730504704,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"72be20be-6022-4363-b9bb-1d9f2cce0e20"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"tool/result","seq":36,"time":1785730504704,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"b68e7f58-8151-4a5c-974b-2ec5dc11186f"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"step/end","seq":37,"time":1785730504704,"data":{"turn":1,"step":3}} {"type":"step/start","seq":38,"time":1785730504712,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -43,9 +43,9 @@ {"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}} {"type":"assistant/chunk","seq":42,"time":1785498587489,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":43,"time":1785730504713,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":44,"time":1785730504713,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"87c2857f-5da3-4050-b081-e044e207be88"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"assistant/message","seq":44,"time":1785730504713,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3ce15df8-96d0-48a6-a1bd-908f779980bb"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} {"type":"tool/call","seq":45,"time":1785730504713,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","seq":46,"time":1785730504721,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"c6e3ff43-809c-4bd8-ba88-da7294c3385a"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"tool/result","seq":46,"time":1785730504721,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"2809ea63-1fa4-4878-b610-8e05ac36b294"}},"sourceEventSeqs":[45],"surfaceOp":"append"} {"type":"step/end","seq":47,"time":1785730504721,"data":{"turn":1,"step":4}} {"type":"step/start","seq":48,"time":1785730504730,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -53,9 +53,9 @@ {"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}}} {"type":"assistant/chunk","seq":52,"time":1785498587503,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":53,"time":1785730504731,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":54,"time":1785730504731,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5b53e0d5-1c68-4988-9fab-d885a9122fe8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"assistant/message","seq":54,"time":1785730504731,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5c7f9390-88ed-48cf-9ada-099320bac560"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} {"type":"tool/call","seq":55,"time":1785730504731,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} -{"type":"tool/result","seq":56,"time":1785730504738,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"20e3fe4d-3d9d-4771-b75d-1f287fc20048"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"tool/result","seq":56,"time":1785730504738,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"d16c935f-465d-4dd8-882c-fe2dda09357c"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","seq":57,"time":1785730504738,"data":{"turn":1,"step":5}} {"type":"step/start","seq":58,"time":1785730504746,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -63,9 +63,9 @@ {"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}}} {"type":"assistant/chunk","seq":62,"time":1785498587517,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":63,"time":1785730504747,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":64,"time":1785730504747,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"178fb9bc-859c-484d-8376-096a705de30a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","seq":64,"time":1785730504747,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b8603c4c-d8b7-4675-a8cd-7e5ea8b949a8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} {"type":"tool/call","seq":65,"time":1785730504747,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} -{"type":"tool/result","seq":66,"time":1785730504755,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"584673b3-9221-4a42-b9e4-69ce1b9f4d60"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","seq":66,"time":1785730504755,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"47a7e409-fff1-4102-a4bb-be6bd6f7db53"}},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","seq":67,"time":1785730504755,"data":{"turn":1,"step":6}} {"type":"step/start","seq":68,"time":1785730504763,"data":{"turn":1,"step":7}} {"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -73,6 +73,6 @@ {"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":72,"time":1785498587531,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} {"type":"assistant/chunk","seq":73,"time":1785730504764,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":74,"time":1785730504764,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"23d457ba-b690-4bb9-b434-86f43c9f4da5"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"assistant/message","seq":74,"time":1785730504764,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b6ccfcf3-5079-4c17-a361-610e1e588065"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} {"type":"step/end","seq":75,"time":1785730504764,"data":{"turn":1,"step":7}} {"type":"turn/end","seq":76,"time":1785730504764,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index f2cffb4010..031b23ae24 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -228,7 +228,9 @@ describe('ACP prompt lifecycle', () => { await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] })) .resolves.toEqual({ stopReason: 'end_turn' }) - await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') }) + // 'partial' is the cancelled turn's finalized prefix update; 'next' proves + // the second prompt settled independently of the aborted turn's late end. + await vi.waitFor(() => { expect(messageText(harness!)).toBe('partialnext') }) }) it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => { diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts index 162f34d5ff..9b7f1f7c9f 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -283,7 +283,10 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] const request = index === undefined ? undefined : requests[index] updateAssistant(index, { completedAt: sourceEvent.time, - status: 'complete', + // A cancellation-finalized prefix is not a completed request: leave it + // running so the following step/end classifies it as before the prefix + // event existed. + status: sourceEvent.data.interrupted === true ? 'running' : 'complete', resultSeq: sourceEvent.seq, provenance: { provider: sourceEvent.data.message.source.provider, diff --git a/packages/client/runtime/tests/request-inspection.spec.ts b/packages/client/runtime/tests/request-inspection.spec.ts index 031c2f4b45..5af1e1b3e6 100644 --- a/packages/client/runtime/tests/request-inspection.spec.ts +++ b/packages/client/runtime/tests/request-inspection.spec.ts @@ -85,6 +85,31 @@ describe('inspectRequests', () => { expect(snapshot.callSchemas.get('call-1')?.name).toBe('read') }) + it('leaves a cancellation-finalized prefix uncompleted so the step boundary classifies it', () => { + const events = [ + at(0, 'step/start', { turn: 1, step: 1 }), + at(1, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'model' }, system: 'system' }, + }), + at(2, 'assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'cut short' }], + source: { provider: 'fake', model: 'model' }, + }), + interrupted: true, + }), + at(3, 'step/end', { turn: 1, step: 1 }), + at(4, 'turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }), + ] + const snapshot = inspectRequests(entriesOf(events)) + expect(snapshot.requests).toMatchObject([ + { purpose: 'assistant', resultSeq: 2, status: 'error' }, + ]) + }) + 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([ 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..548ae54aca 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -162,6 +162,10 @@ function finalNode( firstTokenTime: state.firstTokenTime ?? null, completedTime: event.time, }, + // A cancellation-finalized prefix keeps its truncation marker: the + // durable event carries the classification, so the settled node still + // renders as interrupted (Stopped chip) rather than a completed answer. + ...event.data.interrupted === true ? { interrupted: true } : {}, } } const location = context.start?.location ?? context.matches.at(-1)?.location diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index db9c0ccdd4..3697ac44a2 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -158,6 +158,22 @@ describe('built-in conversation node Definitions', () => { expect(interrupted?.data).toMatchObject({ status: 'interrupted' }) expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true) + // A cancellation-finalized prefix event keeps its durable truncation + // marker through the settled projection. + const markedValue = assembler([ + at(20, 'turn/start', { turn: 3 }), + at(21, 'step/start', { turn: 3, step: 1 }), + at(22, 'assistant/message', { + turn: 3, + step: 1, + message: assistantMessage('assistant-3', 'cut short'), + interrupted: true, + }, { surfaceOp: 'append' }), + ]) + const marked = node(snapshot(markedValue), 'assistant-step') + expect(marked?.data).toMatchObject({ status: 'interrupted', blocks: [{ kind: 'text', text: 'cut short' }] }) + expect((marked?.data as AssistantChatData).finalNode?.interrupted).toBe(true) + const hiddenValue = assembler([ at(20, 'turn/start', { turn: 3 }), at(21, 'step/start', { turn: 3, step: 1 }), diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index c3fb9737b8..aeda41eb59 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -367,6 +367,11 @@ export class ReactLoopAgent implements Agent { signal.throwIfAborted() const finish = assembler.finish if (finish.kind === 'error' || finish.kind === 'aborted') { + // A failed attempt is never finalizable: provider failures commit + // nothing, and a cancel landing during recovery (typically the + // llm/retry backoff, after clients reset the streamed rendering) + // must not resurrect the failed stream's prefix. + attempt = undefined const action = await this.dispatch.waterfall( 'agent/request-error', { turn, @@ -382,7 +387,6 @@ export class ReactLoopAgent implements Agent { if (action?.kind !== 'retry') { throw new LlmError(finish.failure.message, finish.failure.code, finish.failure) } - attempt = undefined continue } @@ -444,6 +448,7 @@ export class ReactLoopAgent implements Agent { turn, step, message, + interrupted: true, ...attempt.assembler.usage === undefined ? {} : { usage: attempt.assembler.usage }, }, { surfaceOp: 'append', sourceEventSeqs: attempt.chunkSeqs }, diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index f8ecc4680f..dabc30ed2f 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -490,10 +490,12 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) // The prefix the user watched stream is committed as the step's message, - // citing exactly the chunk events that delivered it. + // carrying the truncation marker and citing exactly the chunk events that + // delivered it. const message = agent.session.events.find(e => e.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'text', text: 'partial' }]) + expect(message?.type === 'assistant/message' ? message.data.interrupted : undefined).toBe(true) const chunkSeqs = agent.session.events.filter(e => e.type === 'assistant/chunk').map(e => e.seq) expect(message?.sourceEventSeqs).toEqual(chunkSeqs) const types = agent.session.events.map(e => e.type) @@ -559,6 +561,61 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) }) + it('cancel during error recovery does not finalize the failed stream', async () => { + const adapter = new MockAdapter([[ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'doomed partial' }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'SERVER_ERROR' } } }, + ]]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('recovery-cancel'), { provider: 'mock', model: 'mock' }) + // Cancellation lands while agent/request-error is in flight — the window + // dsh-llm-retry opens when its backoff waits after appending llm/retry. + ctx.on('agent/request-error', async ({ agent: subject }) => { + if (subject === agent) subject.cancel({ kind: 'user' }) + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // The failed stream's prefix stays off the surface: clients reset it on + // retry, and provider failures commit nothing. + expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + const end = agent.session.events.find(e => e.type === 'turn/end') + expect(end?.type === 'turn/end' ? end.data.reason.kind : undefined).toBe('aborted') + }) + + it('retry discards the failed attempt; the final message cites only its own chunks', async () => { + const adapter = new MockAdapter([ + [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'doomed partial' }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'SERVER_ERROR' } } }, + ], + textResponse('recovered'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('retry-discards-content'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/request-error', async () => ({ kind: 'retry' as const })) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const messages = agent.session.events.filter(e => e.type === 'assistant/message') + expect(messages).toHaveLength(1) + const message = messages[0]! + expect(message.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'text', text: 'recovered' }]) + expect(message.type === 'assistant/message' ? message.data.interrupted : undefined).toBeUndefined() + // The abandoned attempt's chunks stay out of the completion's source set. + const doomedSeqs = agent.session.events + .filter(e => e.type === 'assistant/chunk' + && e.data.chunk.type === 'text-delta' && e.data.chunk.text === 'doomed partial') + .map(e => e.seq) + expect(doomedSeqs).toHaveLength(1) + expect(message.sourceEventSeqs).not.toContain(doomedSeqs[0]) + }) + it('cancel before any visible content finalizes nothing', async () => { const adapter = new MockAdapter([{ hangAfter: [ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 22468b62c4..a94d4618f7 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -250,10 +250,11 @@ export interface SessionEventMap { * the model output and its accounting travel together (there is no separate * usage record). `usage` is absent when the adapter reported none. A turn * cancelled mid-stream finalizes its delivered text/reasoning prefix as this - * event (undispatched tool calls are absent); an aborted turn with no such - * event streamed no visible content. + * event with `interrupted: true` (undispatched tool calls are absent), so + * consumers classify the truncation without re-deriving it from turn + * boundaries; an aborted turn with no such event streamed no visible content. */ - 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } /** * The model requested one tool invocation: `name` with the raw `arguments` * JSON string exactly as the model produced it (unparsed). `callId` pairs the diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index d65a1bc796..e7b2bf0292 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -27,7 +27,8 @@ interface PartialBlock { * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 3543d0fc98..f93f630caf 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -2583,7 +2583,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: '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}', + declaration: '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 interrupted?: true;\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}', }, { name: 'SessionEventMetadataFilter', From da403d60863f6bd2800168a5ed656e6cb5fb705e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 11 Aug 2026 23:58:38 +0800 Subject: [PATCH 008/180] feat(subprocess): Windows terminal inspection and signalling createProcessInspector now returns a WindowsProcessInspector on win32 instead of throwing: Toolhelp32 tree enumeration with GetProcessTimes start identities, the shell pid as a pseudo foreground group, taskkill tree signalling, and inspector-verified Windows teardown (node-pty signal kills throw on Windows, and externally taskkilled shells may never fire its exit notification, so the handle settles \done\ from the verified absence). subprocess-local and pty-local suites now run on Windows with platform gates; the koffi-backed inspector joins the windows-only coverage exclusions on Linux and is fully covered by the windows-native lane. Also flips vitest.config so subprocess-local and pty-local sources are coverage-required on win32, and adapts the spawn/terminal suites to run natively there (node-translated shell commands, injected POSIX group paths, taskkill signal semantics). --- .../subprocess/subprocess-local/package.json | 1 + .../subprocess-local/src/process-inspector.ts | 2 + .../subprocess-local/src/terminal.ts | 74 +++++ .../subprocess-local/src/windows-inspector.ts | 288 ++++++++++++++++++ .../subprocess-local/tests/local.spec.ts | 17 +- .../tests/process-inspector.spec.ts | 6 +- .../subprocess-local/tests/spawn.spec.ts | 177 +++++++++-- .../subprocess-local/tests/terminal.spec.ts | 127 +++++++- .../tests/windows-inspector.spec.ts | 142 +++++++++ pnpm-lock.yaml | 6 + vitest.config.ts | 15 +- 11 files changed, 809 insertions(+), 46 deletions(-) create mode 100644 packages/subprocess/subprocess-local/src/windows-inspector.ts create mode 100644 packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 12f47c0400..ad89719667 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "koffi": "^3.1.0", "node-pty": "^1.1.0" }, "devDependencies": { diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index f31de010de..89effc0082 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -3,6 +3,7 @@ import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs' import { execFileSync } from 'node:child_process' import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' +import { createWindowsProcessInspector } from './windows-inspector.ts' /** PID plus start identity, preventing teardown escalation after PID reuse. */ export interface ProcessIdentity { @@ -370,5 +371,6 @@ export function createProcessInspector( ): ProcessInspector { if (platform === 'linux') return new LinuxProcessInspector(arch, internals) if (platform === 'darwin') return new MacProcessInspector(internals) + if (platform === 'win32') return createWindowsProcessInspector() throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`) } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 11d13a405a..6d3b981169 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -50,11 +50,13 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { * @param terminal - allocated node-pty process. * @param inspector - platform process/session operations. * @param graceMs - TERM-to-KILL and exit-wait grace. + * @param platform - host platform; defaults to the running platform, injectable for deterministic tests. */ constructor( private readonly terminal: IPty, private readonly inspector: ProcessInspector, private readonly graceMs: number, + private readonly platform: NodeJS.Platform = process.platform, ) { this.pid = terminal.pid this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid) @@ -98,6 +100,19 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) { throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead') } + if (this.platform === 'win32') { + if (signal === 'SIGINT') { + // Windows has no process-group signalling: a `\x03` input write is the + // Ctrl-C delivery path conhost turns into a console-wide CTRL_C event + // for attached processes. node-pty's signal kills throw on Windows, so + // no signal ever reaches the inspector. + this.terminal.write('\x03') + return foreground.processGroupId + } + if (signal === 'SIGTSTP' || signal === 'SIGHUP') { + throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`) + } + } this.inspector.signalGroup(foreground.processGroupId, signal) return foreground.processGroupId } @@ -177,6 +192,10 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } private async stopShell(): Promise { + if (this.platform === 'win32') { + await this.stopShellWindows() + return + } if (!this.exited) { try { this.terminal.kill('SIGTERM') @@ -196,6 +215,44 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`) } + private async stopShellWindows(): Promise { + // node-pty's Windows kill(signal) throws ("Signals not supported on + // windows"), and its bare kill() delegates to a console-list agent that + // fails when the parent has no console. taskkill tree escalation is the + // teardown path, fenced on the shell's start identity like every + // descendant; a root identity miss falls back to the bare kill. taskkill + // termination also does not reliably fire node-pty's exit notification + // (the same console-list agent), so the tiers verify the shell's absence + // through the inspector instead of waiting on `done` alone. + const shellGone = (): boolean => + this.exited || (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) + if (!shellGone() && this.rootIdentity !== undefined) { + this.inspector.signalProcess(this.rootIdentity, 'SIGTERM') + await this.waitForWindowsShellExit() + } + if (!shellGone() && this.rootIdentity === undefined) { + try { + this.terminal.kill() + } catch (_topLevelAlreadyExitedDuringKill) { + // The exit callback is authoritative. + } + await Promise.race([this.done.then(() => undefined), delay(this.graceMs)]) + } + if (!shellGone() && this.rootIdentity !== undefined) { + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') + await this.waitForWindowsShellExit() + } + if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`) + } + + private async waitForWindowsShellExit(): Promise { + const until = Date.now() + this.graceMs + while (!this.exited && Date.now() < until) { + if (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) return + await delay(Math.min(25, Math.max(1, until - Date.now()))) + } + } + private async closeOnce(): Promise { let survivors = await this.stopDescendants() if (survivors.length > 0) { @@ -206,7 +263,24 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (survivors.length > 0) { throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`) } + this.settleExitIfGone() this.dataDisposable.dispose() this.exitDisposable.dispose() } + + private settleExitIfGone(): void { + // An externally taskkilled Windows shell may never fire node-pty's exit + // notification (its console-list agent fails without a parent console), + // which would leave `done` — and every consumer awaiting it — unsettled + // forever. Teardown has just verified the shell's absence through the + // inspector, so a missing exit event is itself the outcome. + if (this.platform !== 'win32') return + if (this.exited) return + /* v8 ignore next -- stopShellWindows() verified the shell is gone or threw; + the identity re-check is a defensive fence for a future caller. */ + if (this.rootIdentity !== undefined && this.inspector.isAlive(this.rootIdentity)) return + this.exited = true + this.output.end() + this.outcome.resolve({ exitCode: null, signal: null }) + } } diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts new file mode 100644 index 0000000000..78bea583d8 --- /dev/null +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -0,0 +1,288 @@ +/** + * Windows process-table operations for terminal readiness, signalling, and + * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time + * identity, the shell pid as a pseudo process group (Windows has no POSIX + * groups), and taskkill tree signalling. The koffi bindings load lazily so + * non-Windows processes never touch Win32 libraries; all decision logic takes + * an injectable internals boundary so suites can pin it on any host. + * @module dsh-subprocess-local/windows-inspector + */ + +import { spawnSync } from 'node:child_process' +import koffi from 'koffi' +import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' +import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts' + +/** One Toolhelp32 process-table row. */ +export interface ProcessEntry { + pid: number + parentPid: number +} + +/** Injectable Windows process operations used by one local PTY session. */ +export interface WindowsProcessInspectorInternals { + /** Enumerate the current process table (pid/parent pairs). */ + snapshot(): ProcessEntry[] + /** Return one process's creation-time identity, or undefined when unreadable. */ + creationTime(pid: number): string | undefined + /** Terminate one process tree; `force` maps to taskkill `/F`. */ + taskkill(pid: number, force: boolean): void +} + +/** + * Walk a process table from one root in children-first order, retaining only + * members whose start identity is readable (unreadable members are detector + * misses, exactly like an unreadable `/proc` entry on Linux). + * @param entries - the process table snapshot. + * @param rootPid - the tree root to descend from. + * @param started - creation-time identity resolver for one member. + * @returns the root and its current transitive descendants, children first. + */ +export function windowsProcessTree( + entries: ProcessEntry[], + rootPid: number, + started: (pid: number) => string | undefined, +): ProcessIdentity[] { + const byPid = new Map(entries.map(entry => [entry.pid, entry])) + const root = byPid.get(rootPid) + if (root === undefined) return [] + const byParent = new Map() + for (const entry of entries) { + const children = byParent.get(entry.parentPid) ?? [] + children.push(entry) + byParent.set(entry.parentPid, children) + } + const visited = new Set() + const result: ProcessIdentity[] = [] + const visit = (entry: ProcessEntry): void => { + if (visited.has(entry.pid)) return + visited.add(entry.pid) + for (const child of byParent.get(entry.pid) ?? []) visit(child) + const identity = started(entry.pid) + if (identity !== undefined) result.push({ pid: entry.pid, started: identity }) + } + visit(root) + return result +} + +/** + * Windows {@link ProcessInspector}. The shell pid stands in for a foreground + * process group: it is a stable pseudo-group that lets the prompt-marker + * readiness path compare foreground identities, while every actual signal + * targets the console-wide tree through taskkill (SIGINT is delivered by the + * terminal handle as a `\x03` input write and never reaches this layer). + */ +export class WindowsProcessInspector implements ProcessInspector { + constructor( + private readonly internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(), + ) {} + + foregroundPgid(shellPid: number): number { + return shellPid + } + + isStdinWaiting(_pgid: number): boolean { + return false + } + + processTree(rootPid: number): ProcessIdentity[] { + return windowsProcessTree(this.internals.snapshot(), rootPid, this.internals.creationTime) + } + + processSession(_sessionId: number): ProcessIdentity[] { + return [] + } + + isAlive(identity: ProcessIdentity): boolean { + const started = this.internals.creationTime(identity.pid) + return started !== undefined && started === identity.started + } + + signalGroup(pgid: number, signal: SubprocessTerminalSignal): void { + this.internals.taskkill(pgid, signal === 'SIGKILL') + } + + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void { + if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL') + } +} + +/** + * Create the Windows process inspector. + * @param internals - injectable process operations; defaults to the koffi-backed table. + * @returns the Windows inspector. + */ +export function createWindowsProcessInspector( + internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(), +): WindowsProcessInspector { + return new WindowsProcessInspector(internals) +} + +/** Terminate one Windows process tree with taskkill, contained like POSIX group signalling. */ +function taskkillTree(pid: number, force: boolean): void { + if (pid <= 0) return + // Outcome deliberately unchecked: an already-absent tree, exit races, and a + // missing taskkill binary are as tolerable here as ESRCH is for POSIX. + spawnSync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], { stdio: 'ignore' }) +} + +declare const nativePtr: unique symbol +/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */ +export type NativePtr = bigint & { readonly [nativePtr]: true } + +/** + * True for NULL and INVALID_HANDLE_VALUE returns from Win32 handle APIs. + * @param value - a handle as koffi may hand it back (pointer, null, or 0n). + * @returns whether the value signals an invalid handle. + */ +export function isInvalidHandle(value: NativePtr | null | undefined): boolean { + if (value === null || value === undefined) return true + const asBigInt = value as bigint + return asBigInt === 0n || asBigInt === 0xFFFFFFFFFFFFFFFFn || asBigInt === -1n +} + +/** The lazy koffi binding table: every Win32 call the Windows inspector uses. */ +interface Win32Bindings { + createToolhelp32Snapshot(flags: number, processId: number): NativePtr + process32FirstW(snapshot: NativePtr, entry: NativePtr): number + process32NextW(snapshot: NativePtr, entry: NativePtr): number + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr + getProcessTimes( + process: NativePtr, + creation: NativePtr, + exit: NativePtr, + kernel: NativePtr, + user: NativePtr, + ): number + closeHandle(handle: NativePtr): number +} + +const PVOID: ReturnType = koffi.pointer('void') + +/** + * Resolve the koffi Win32 struct types once. Registration is lazy and cached + * because koffi's type registry is global per process: test runners that + * re-evaluate this module (a hoisted `vi.mock` re-imports the graph) must not + * re-register the names. + */ +function win32Structs(): { PROCESSENTRY32W: ReturnType; FILETIME: ReturnType } { + if (cachedStructs !== undefined) return cachedStructs + // koffi PROCESSENTRY32W layout (tlhelp32.h); the size assert pins the x64 layout. + const PROCESSENTRY32W = koffi.struct('PROCESSENTRY32W', { + dwSize: 'uint32', + cntUsage: 'uint32', + th32ProcessID: 'uint32', + th32DefaultHeapID: PVOID, + th32ModuleID: 'uint32', + cCntThreads: 'uint32', + th32ParentProcessID: 'uint32', + pcPriClassBase: 'int32', + dwFlags: 'uint32', + szExeFile: koffi.array('char16', 260), + }) + // koffi FILETIME layout (minwinbase.h): two 32-bit halves of the 64-bit timestamp. + const FILETIME = koffi.struct('FILETIME', { + dwLowDateTime: 'uint32', + dwHighDateTime: 'uint32', + }) + /* v8 ignore start -- a layout-mismatch guard fires only on ABI breakage; the windows-native suites exercise the real struct. */ + if (PROCESSENTRY32W.size !== 568) { + throw new Error(`PROCESSENTRY32W layout mismatch: koffi computed ${PROCESSENTRY32W.size}, Windows headers say 568`) + } + /* v8 ignore stop */ + cachedStructs = { PROCESSENTRY32W, FILETIME } + return cachedStructs +} + +let cachedStructs: ReturnType | undefined + +const TH32CS_SNAPPROCESS = 0x2 +const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + +let cachedBindings: Win32Bindings | undefined + +/** + * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). + * @returns the cached binding table. + */ +function win32Bindings(): Win32Bindings { + if (cachedBindings !== undefined) return cachedBindings + const { PROCESSENTRY32W, FILETIME } = win32Structs() + const kernel32 = koffi.load('kernel32.dll') + const bind = ( + name: string, + result: ReturnType | string, + args: Array | string>, + ): unknown => kernel32.func('__stdcall', name, result, args) + cachedBindings = { + createToolhelp32Snapshot: bind('CreateToolhelp32Snapshot', PVOID, ['uint32', 'uint32']), + process32FirstW: bind('Process32FirstW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]), + process32NextW: bind('Process32NextW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]), + openProcess: bind('OpenProcess', PVOID, ['uint32', 'int', 'uint32']), + getProcessTimes: bind('GetProcessTimes', 'int', [ + PVOID, + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + ]), + closeHandle: bind('CloseHandle', 'int', [PVOID]), + } as unknown as Win32Bindings + return cachedBindings +} + +/** Enumerate the current process table through Toolhelp32. */ +function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { + const { PROCESSENTRY32W } = win32Structs() + const snapshot = bindings.createToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + /* v8 ignore next -- an invalid snapshot for the process flag is not producible through the public API; + the guard mirrors POSIX's unreadable-proc tolerance and isInvalidHandle is unit-tested. */ + if (isInvalidHandle(snapshot)) return [] + const entries: ProcessEntry[] = [] + try { + const entry = koffi.alloc(PROCESSENTRY32W, 1) + koffi.encode(entry, 'uint32', PROCESSENTRY32W.size) + let ok = bindings.process32FirstW(snapshot, entry) + while (ok !== 0) { + const record = koffi.decode(entry, PROCESSENTRY32W) as { + th32ProcessID: number + th32ParentProcessID: number + } + entries.push({ pid: record.th32ProcessID, parentPid: record.th32ParentProcessID }) + ok = bindings.process32NextW(snapshot, entry) + } + } finally { + bindings.closeHandle(snapshot) + } + return entries +} + +/** Read one process's creation-time identity through GetProcessTimes. */ +function windowsCreationTime(bindings: Win32Bindings, pid: number): string | undefined { + const { FILETIME } = win32Structs() + const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) + if (isInvalidHandle(handle)) return undefined + try { + const creation = koffi.alloc(FILETIME, 1) + const exit = koffi.alloc(FILETIME, 1) + const kernel = koffi.alloc(FILETIME, 1) + const user = koffi.alloc(FILETIME, 1) + /* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and + cannot be staged deterministically; the absent-process path is covered and the caller + treats undefined as a detector miss. */ + if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return undefined + const record = koffi.decode(creation, FILETIME) as { dwLowDateTime: number; dwHighDateTime: number } + return `${record.dwHighDateTime}:${record.dwLowDateTime}` + } finally { + bindings.closeHandle(handle) + } +} + +/** The koffi-backed default internals; bindings resolve lazily on first use. */ +function defaultWindowsProcessInternals(): WindowsProcessInspectorInternals { + return { + snapshot: () => snapshotWindowsProcesses(win32Bindings()), + creationTime: pid => windowsCreationTime(win32Bindings(), pid), + taskkill: taskkillTree, + } +} diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index e3131543f4..88f1b351fb 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -7,8 +7,16 @@ import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalS import { childEnv } from '../src/spawn.ts' function spec(command: string, overrides: Partial = {}): SubprocessSpawnSpec { + // Windows has no bash; the suite's simple commands translate to node one-liners. + const argv = process.platform === 'win32' + ? [process.execPath, '-e', { + 'echo managed': 'console.log("managed")', + 'sleep 60': 'setTimeout(() => {}, 60000)', + 'true': '', + }[command] ?? command] + : ['bash', '-c', command] return { - argv: ['bash', '-c', command], + argv, cwd: process.cwd(), stdio: { stdin: 'ignore', @@ -60,9 +68,9 @@ describe('LocalSubprocessService', () => { const explicit = childEnv({ Path: '/bin', PathExt: '.EXE;.CMD' }) expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATH')).toEqual(['Path']) expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATHEXT')).toEqual(['PathExt']) - expect(candidates('tool', explicit)).toEqual(['/bin/tool.EXE', '/bin/tool.CMD']) + expect(candidates('tool', explicit)).toEqual([resolve('/bin', 'tool.EXE'), resolve('/bin', 'tool.CMD')]) expect(candidates('tool', { Path: '/ambient', PATH: '/explicit', PATHEXT: '.EXE' })) - .toEqual(['/explicit/tool.EXE']) + .toEqual([resolve('/explicit', 'tool.EXE')]) expect(candidates('tool.exe', {})).toEqual([resolve(process.cwd(), 'tool.exe')]) expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4) await expect(ctx.subprocess.resolveExecutable(String.raw`bin\server.exe`)) @@ -286,7 +294,8 @@ describe('LocalSubprocessService', () => { const handle = ctx.subprocess.spawn(spec('sleep 60')) await fiber.dispose() const outcome = await handle.done - expect(outcome.signal).toBe('SIGTERM') + // Windows teardown terminates through taskkill, which reports no signal. + expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) it('a settled process leaves the live set (disposal does not re-kill it)', async () => { diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index c90a7b3490..aadf2e1388 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -5,6 +5,7 @@ import { parseProcStat, } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' +import { WindowsProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string { const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)] @@ -237,12 +238,13 @@ describe('macOS process inspector', () => { ]) }) - it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => { + it('returns undefined for missing or invalid foreground groups and dispatches platform inspectors', () => { const fake = fakeInternals() fake.setTpgid('-1') expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined() fake.internals.exec = () => { throw new Error('gone') } expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined() - expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported on platform win32') + expect(createProcessInspector('win32', 'x64', fake.internals)).toBeInstanceOf(WindowsProcessInspector) + expect(() => createProcessInspector('freebsd', 'x64', fake.internals)).toThrow('unsupported on platform freebsd') }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4cffde6432..d0fcc422fb 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { + childEnv, killGroup, OutputCollector, spawnSubprocess, @@ -11,6 +12,49 @@ import { import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +/** + * Translate the suite's POSIX command strings into node one-liners on Windows, + * where no bash exists; the translated commands keep the same observable + * stdout/stderr/exit-code contract the bash originals pin on POSIX. + * @param command - the bash `-c` command string used by the test. + * @returns the argv to spawn. + */ +function shellArgv(command: string): string[] { + if (process.platform !== 'win32') return ['bash', '-c', command] + const node = (script: string): string[] => [process.execPath, '-e', script] + switch (command) { + case 'true': return node('') + case 'echo hello': return node('console.log("hello")') + case 'echo hi': return node('console.log("hi")') + case 'echo oops >&2': return node('console.error("oops")') + case 'echo err >&2': return node('console.error("err")') + case 'echo out; echo err >&2': return node('console.log("out"); console.error("err")') + case 'echo out; echo to-parent >&2': return node('console.log("out"); console.error("to-parent")') + case 'echo to-parent; echo err >&2': return node('console.log("to-parent"); console.error("err")') + case 'exit 42': return node('process.exit(42)') + case 'exit 7': return node('process.exit(7)') + case 'pwd': return node('console.log(process.cwd())') + case 'sleep 60': return node('setTimeout(() => {}, 60000)') + case 'cat': return node('process.stdin.pipe(process.stdout)') + case 'unused': return node('') + case 'echo "${TERM:-unset}"': return node('console.log(process.env.TERM ?? "unset")') + case 'echo "$EXTRA_ONE/$EXTRA_TWO"': return node('console.log(process.env.EXTRA_ONE + "/" + process.env.EXTRA_TWO)') + case 'echo "$EXPLICIT_OVERRIDE_PASSWORD"': return node('console.log(process.env.EXPLICIT_OVERRIDE_PASSWORD)') + case 'echo "${SUBPROCESS_TOMBSTONE_PROBE:-absent}"': return node('console.log(process.env.SUBPROCESS_TOMBSTONE_PROBE ?? "absent")') + case 'echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"': + return node('console.log("[" + [process.env.DSH_STALE ?? "absent", process.env.DSH_SHELL, process.env.DSH_SESSION_ID].join("|") + "]")') + case 'echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${SUBPROCESS_TEST_PASSWORD:-absent}|${DSH_TEST_PLAIN:-absent}]"': + return node('console.log("[" + [process.env.DSH_TEST_API_KEY ?? "absent", process.env.DSH_TEST_TOKEN ?? "absent", process.env.SUBPROCESS_TEST_PASSWORD ?? "absent", process.env.DSH_TEST_PLAIN ?? "absent"].join("|") + "]")') + case 'printf "%.0sx" $(seq 1 500)': return node('process.stdout.write("x".repeat(500))') + case 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2': + return node('process.stdout.write("x".repeat(500)); process.stderr.write("e".repeat(500))') + case 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done': + return node('for (let i = 1; i <= 200; i++) console.log("line-" + String(i).padStart(4, "0"))') + default: + throw new Error(`spawn.spec: no win32 node translation for ${JSON.stringify(command)}`) + } +} + const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, failNextUnlink: { value: false }, @@ -48,7 +92,7 @@ type SpecOverrides = Partial[0]> & { function spec(command: string, overrides: SpecOverrides = {}) { const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides return { - argv: ['bash', '-c', command], + argv: shellArgv(command), cwd: process.cwd(), stdio: { stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const, @@ -161,7 +205,7 @@ describe('spawnSubprocess', () => { expect(result.stdout.text).toBe('callers-choice\n') }) - it('runs in the requested cwd', async () => { + it.skipIf(process.platform === 'win32')('runs in the requested cwd', async () => { const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' }))) expect(result.stdout.text.trim()).toMatch(/\/tmp$/) }) @@ -176,11 +220,12 @@ describe('spawnSubprocess', () => { setTimeout(() => { controller.abort('deadline') }, 100) const result = await running.done expect(Date.now() - start).toBeLessThan(5_000) - expect(result.signal).toBe('SIGTERM') - expect(result.exitCode).toBeNull() + // Windows teardown terminates through taskkill, which reports no signal. + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') + expect(result.exitCode).toBe(process.platform === 'win32' ? 1 : null) }) - it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => { + it.skipIf(process.platform === 'win32')('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => { const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) await waitForStdout(running, 'ready\n') running.terminate() @@ -231,12 +276,16 @@ describe('spawnSubprocess', () => { expect(forceSignals).toBe(0) } finally { killSpy.mockRestore() - process.kill(helper, 'SIGKILL') + try { + process.kill(helper, 'SIGKILL') + } catch { + // taskkill already took the helper down on Windows. + } await waitGone(helper) } }) - it('terminates the whole process group (grandchildren die too)', async () => { + it.skipIf(process.platform === 'win32')('terminates the whole process group (grandchildren die too)', async () => { // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) @@ -255,7 +304,7 @@ describe('spawnSubprocess', () => { const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('user cancelled') }, 50) const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) it('throws when the signal is already aborted before spawn', () => { @@ -275,10 +324,10 @@ describe('spawnSubprocess', () => { running.terminate() running.terminate() const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) - it('does not wait for a Linux group that has only zombie members', async () => { + it.skipIf(process.platform === 'win32')('does not wait for a Linux group that has only zombie members', async () => { const pidFile = join(spillDir, `zombie-group-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo leader-done`, { graceMs: 100 }), { platform: 'linux', @@ -296,7 +345,7 @@ describe('spawnSubprocess', () => { } }) - it('bounds inherited-pipe draining after the shell exits', async () => { + it.skipIf(process.platform === 'win32')('bounds inherited-pipe draining after the shell exits', async () => { const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) const started = Date.now() const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) @@ -328,7 +377,7 @@ describe('stdin and extra env (set by in-process plugins)', () => { expect(result.stdout.text).toBe('') }) - it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { + it.skipIf(process.platform === 'win32')('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { // With no bytes, fd 0 remains the pre-spawn `ignore` default (/dev/null, a character device). // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other'))) @@ -600,7 +649,7 @@ describe('windows tree semantics (injected platform)', () => { running.terminate() const outcome = await running.done expect(killed).toContain(running.pid) - expect(outcome.signal).toBe('SIGKILL') + expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGKILL') }) it('waitForExit falls back to direct-child liveness where groups do not exist', async () => { @@ -611,7 +660,7 @@ describe('windows tree semantics (injected platform)', () => { }) describe('waitForExit', () => { - it('waits for the whole detached tree, not just the shell', async () => { + it.skipIf(process.platform === 'win32')('waits for the whole detached tree, not just the shell', async () => { const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) const grandchild = await waitForPidFile(pidFile) @@ -631,7 +680,7 @@ describe('waitForExit', () => { }) }) -describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { +describe.skipIf(process.platform === 'win32')('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { // The leader spawns a TERM-trapping helper with all stdio detached from // the collected pipes, then exits: the helper holds the GROUP alive while @@ -697,6 +746,94 @@ describe('coverage seams', () => { expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow() }) + it('covers the injected POSIX group paths on any host', async () => { + // Windows has no POSIX groups, so the tree-liveness probe, group + // signalling, and the SIGKILL escalation timer only run here through the + // injected platform; the mock keeps the group alive through TERM and + // terminates the direct child when the escalation tier delivers SIGKILL. + const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { + platform: 'linux', + linuxProcessGroupHasLiveMembers: () => false, + }) + const realKill = process.kill.bind(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (typeof target === 'number' && target < 0) { + if (signal === 0) return true + if (signal === 'SIGKILL') realKill(running.pid, 'SIGKILL') + return true + } + return realKill(target, signal) + }) + try { + running.terminate() + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + } finally { + killSpy.mockRestore() + } + }) + + it('treats a vanished group probe as quiescent without signalling', async () => { + const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' }) + const realKill = process.kill.bind(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (typeof target === 'number' && target < 0) { + throw Object.assign(new Error('simulated absent group'), { code: 'ESRCH' }) + } + return realKill(target, signal) + }) + try { + running.terminate() + await new Promise(resolve => setTimeout(resolve, 20)) + realKill(running.pid, 'SIGKILL') + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + } finally { + killSpy.mockRestore() + } + }) + + it('childEnv keeps the POSIX spread on non-Windows hosts', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + try { + expect(childEnv({ DSH_X: '1' }).DSH_X).toBe('1') + } finally { + platform.mockRestore() + } + }) + + it('settles through the pipe-drain timer when a descendant holds a collected pipe', async () => { + // The leader spawns a detached grandchild inheriting the collected stdout + // pipe, then exits: `close` cannot settle while the grandchild holds the + // pipe, so the bounded pipe-drain timer must settle the outcome. + const pidFile = join(spillDir, `pipe-drain-${Date.now()}.pid`) + const childScript = ` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: ['ignore', 1, 2], + }) + writeFileSync(${JSON.stringify(pidFile)}, String(helper.pid)) + helper.unref() + ` + const running = spawnSubprocess({ + ...spec('unused', { graceMs: 100 }), + argv: [process.execPath, '-e', childScript], + }) + const helper = await waitForPidFile(pidFile) + const started = Date.now() + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(Date.now() - started).toBeGreaterThanOrEqual(90) + try { + process.kill(helper, 'SIGKILL') + } catch { + // Already gone; the drain bound is the point under test. + } + await waitGone(helper) + }) + it('a spawn-failed handle rejects done while waitForExit reports gone', async () => { const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' })) await expect(running.done).rejects.toThrow() @@ -833,7 +970,7 @@ describe('argv validation', () => { expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) }) - it('spawns argv verbatim without shell interpretation', async () => { + it.skipIf(process.platform === 'win32')('spawns argv verbatim without shell interpretation', async () => { const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] })) expect(result.stdout.text).toBe('$HOME') }) @@ -853,7 +990,7 @@ describe('abort edge cases', () => { .toThrow(/aborted before spawn: aborted/) }) - it('reports the terminating signal of an externally self-killed command', async () => { + it.skipIf(process.platform === 'win32')('reports the terminating signal of an externally self-killed command', async () => { // spawnSubprocess reports the raw signal; whether it counts as timeout/cancel is the // executor's classification (a self-kill is neither) — see executor.spec.ts. const result = await finish(spawnSubprocess(spec('kill -TERM $$'))) @@ -894,7 +1031,7 @@ describe('environment and spill-file hardening', () => { } }) - it('creates spill files with owner-only permissions and random names', async () => { + it.skipIf(process.platform === 'win32')('creates spill files with owner-only permissions and random names', async () => { const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, @@ -905,7 +1042,7 @@ describe('environment and spill-file hardening', () => { expect(mode).toBe(0o600) }) - it('defaults spills into a private per-process directory', async () => { + it.skipIf(process.platform === 'win32')('defaults spills into a private per-process directory', async () => { const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), )) @@ -931,6 +1068,6 @@ describe('environment and spill-file hardening', () => { const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort() }, 50) const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) }) diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 79501c7dc4..2622719867 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -73,6 +73,8 @@ class FakeInspector implements ProcessInspector { this.groups.push([pgid, signal]) } signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') { + // Mirrors the real inspectors' alive-gated signalling. + if (!this.alive.has(identity.pid)) return if (this.throwProcess) throw new Error('process raced') this.processes.push([identity.pid, signal]) if (this.removeOnSignal) this.alive.delete(identity.pid) @@ -81,12 +83,18 @@ class FakeInspector implements ProcessInspector { afterEach(() => { vi.useRealTimers() }) +function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): LocalTerminalHandle { + // The suite pins POSIX signalling semantics deterministically on every host; + // the win32 branches get their own platform-explicit tests below. + return new LocalTerminalHandle(pty.asPty(), inspector, graceMs, 'linux') +} + describe('LocalTerminalHandle', () => { it('bridges terminal bytes, foreground control, and signalled exit facts', async () => { const pty = new FakePty() const inspector = new FakeInspector() inspector.waiting = true - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) const chunks: Buffer[] = [] handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) }) @@ -107,7 +115,7 @@ describe('LocalTerminalHandle', () => { it('rejects unsafe foreground signals and writes after exit', async () => { const pty = new FakePty() const inspector = new FakeInspector() - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) inspector.pgid = handle.pid await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session') inspector.pgid = undefined @@ -127,7 +135,7 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.removeOnSignal = false - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) const quiescent = handle.terminate() expect(handle.terminate()).toBe(quiescent) @@ -148,7 +156,7 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.removeOnSignal = false - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) pty.emitExit() const waiting = handle.terminate() let settled = false @@ -167,7 +175,7 @@ describe('LocalTerminalHandle', () => { const disowned = { pid: 124, started: 'disowned' } inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : [] inspector.alive.add(124) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) pty.emitExit() @@ -181,7 +189,7 @@ describe('LocalTerminalHandle', () => { const descendant = { pid: 124, started: 'observed' } inspector.members = [descendant] inspector.alive.add(descendant.pid) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) await handle.inspectForeground() inspector.members = [] @@ -194,7 +202,7 @@ describe('LocalTerminalHandle', () => { it('does not adopt the children of a recycled shell pid', async () => { const pty = new FakePty() const inspector = new FakeInspector() - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) pty.emitExit() const imposterChild = { pid: 999, started: 'imposter-child' } @@ -213,7 +221,7 @@ describe('LocalTerminalHandle', () => { const orphan = { pid: 321, started: 'unverifiable' } inspector.members = [orphan] inspector.alive.add(orphan.pid) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() expect(inspector.processes).toEqual([]) @@ -238,7 +246,7 @@ describe('LocalTerminalHandle', () => { } return [] } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']]) expect(pty.kills).toEqual(['SIGTERM']) @@ -252,7 +260,7 @@ describe('LocalTerminalHandle', () => { inspector.sessionMembers = [late] inspector.alive.add(late.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() @@ -270,7 +278,7 @@ describe('LocalTerminalHandle', () => { inspector.sessionMembers = [late] inspector.alive.add(late.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) const first = handle.terminate() const failed = expect(first).rejects.toThrow('surviving pids: 124') @@ -297,7 +305,7 @@ describe('LocalTerminalHandle', () => { inspector.processes.push([identity.pid, signal]) if (signal === 'SIGKILL') inspector.alive.delete(identity.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) const quiescent = handle.terminate() await vi.advanceTimersByTimeAsync(25) await quiescent @@ -308,7 +316,7 @@ describe('LocalTerminalHandle', () => { vi.useFakeTimers() const pty = new FakePty() pty.autoExitOnKill = false - const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10) + const handle = makeHandle(pty, new FakeInspector(), 10) const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123') await vi.advanceTimersByTimeAsync(25) await failed @@ -326,7 +334,98 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.throwProcess = true - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1) + const handle = makeHandle(pty, inspector, 1) await expect(handle.terminate()).rejects.toThrow('surviving pids: 124') }) }) + +describe('LocalTerminalHandle on Windows', () => { + const win32 = 'win32' as NodeJS.Platform + + it('delivers SIGINT as a Ctrl-C input write without inspector signalling', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await expect(handle.signalForeground('SIGINT')).resolves.toBe(456) + expect(pty.writes).toEqual(['\x03']) + expect(inspector.groups).toEqual([]) + }) + + it('rejects SIGTSTP and SIGHUP as unavailable on Windows', async () => { + const handle = new LocalTerminalHandle(new FakePty().asPty(), new FakeInspector(), 10, win32) + await expect(handle.signalForeground('SIGTSTP')).rejects.toThrow('unsupported on Windows') + await expect(handle.signalForeground('SIGHUP')).rejects.toThrow('unsupported on Windows') + }) + + it('routes SIGTERM through the inspector tree with the pseudo foreground group', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await expect(handle.signalForeground('SIGTERM')).resolves.toBe(456) + expect(inspector.groups).toEqual([[456, 'SIGTERM']]) + expect(pty.writes).toEqual([]) + }) + + it('still refuses to SIGKILL the terminal shell on Windows', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + inspector.pgid = handle.pid + await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session') + }) + + it('escalates the shell through taskkill tiers instead of node-pty signal kills', async () => { + vi.useFakeTimers() + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + const quiescent = handle.terminate() + await vi.advanceTimersByTimeAsync(5) + expect(inspector.processes).toEqual([[123, 'SIGTERM']]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + await quiescent + expect(inspector.processes).toEqual([[123, 'SIGTERM']]) + expect(pty.kills).toEqual([]) + }) + + it('reports a shell that survives both taskkill tiers', async () => { + vi.useFakeTimers() + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + inspector.removeOnSignal = false + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123') + await vi.advanceTimersByTimeAsync(25) + await failed + expect(inspector.processes).toEqual([[123, 'SIGTERM'], [123, 'SIGKILL']]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + await handle.terminate() + }) + + it('skips taskkill escalation entirely when the shell already exited', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + pty.emitExit() + await handle.terminate() + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + + it('falls back to the bare node-pty kill when the shell identity was never observable', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.root = undefined + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await handle.terminate() + expect(pty.kills).toHaveLength(1) + expect(inspector.processes).toEqual([]) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts new file mode 100644 index 0000000000..b18945fc10 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { + createWindowsProcessInspector, + isInvalidHandle, + windowsProcessTree, + WindowsProcessInspector, +} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' +import type { + NativePtr, + ProcessEntry, + WindowsProcessInspectorInternals, +} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' + +function fakeInternals() { + const entries: ProcessEntry[] = [] + const times = new Map() + const kills: Array<[number, boolean]> = [] + return { + internals: { + snapshot: () => [...entries], + creationTime: pid => times.get(pid), + taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) }, + } satisfies WindowsProcessInspectorInternals, + add(entry: ProcessEntry, started?: string): void { + entries.push(entry) + if (started !== undefined) times.set(entry.pid, started) + }, + kills, + } +} + +describe('windowsProcessTree', () => { + it('walks a table children-first with readable identities only', () => { + const started = (pid: number): string | undefined => pid === 12 ? undefined : `t${pid}` + expect(windowsProcessTree([ + { pid: 10, parentPid: 0 }, + { pid: 11, parentPid: 10 }, + { pid: 12, parentPid: 11 }, + { pid: 13, parentPid: 11 }, + { pid: 14, parentPid: 10 }, + ], 10, started)).toEqual([ + { pid: 13, started: 't13' }, + { pid: 11, started: 't11' }, + { pid: 14, started: 't14' }, + { pid: 10, started: 't10' }, + ]) + }) + + it('returns an empty walk for an absent root', () => { + expect(windowsProcessTree([{ pid: 10, parentPid: 0 }], 99, () => 't')).toEqual([]) + }) + + it('terminates on a parent cycle instead of recursing forever', () => { + const entries = [ + { pid: 10, parentPid: 11 }, + { pid: 11, parentPid: 10 }, + ] + expect(windowsProcessTree(entries, 10, () => 't')).toHaveLength(2) + }) +}) + +describe('WindowsProcessInspector (injected internals)', () => { + it('exposes the shell pid as the pseudo foreground group and never proves stdin waits', () => { + const fake = fakeInternals() + const inspector = new WindowsProcessInspector(fake.internals) + expect(inspector.foregroundPgid(77)).toBe(77) + expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.processSession(77)).toEqual([]) + }) + + it('delegates tree walks and identity checks to the internals', () => { + const fake = fakeInternals() + fake.add({ pid: 10, parentPid: 0 }, 't10') + fake.add({ pid: 11, parentPid: 10 }, 't11') + const inspector = new WindowsProcessInspector(fake.internals) + expect(inspector.processTree(10)).toEqual([ + { pid: 11, started: 't11' }, + { pid: 10, started: 't10' }, + ]) + expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true) + expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false) + expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false) + }) + + it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => { + const fake = fakeInternals() + const inspector = new WindowsProcessInspector(fake.internals) + inspector.signalGroup(77, 'SIGKILL') + inspector.signalGroup(77, 'SIGTERM') + inspector.signalGroup(0, 'SIGKILL') + expect(fake.kills).toEqual([[77, true], [77, false], [0, true]]) + }) + + it('signals a process only while its start identity matches', () => { + const fake = fakeInternals() + fake.add({ pid: 10, parentPid: 0 }, 't10') + const inspector = new WindowsProcessInspector(fake.internals) + inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL') + inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM') + expect(fake.kills).toEqual([[10, true]]) + }) + + it('accepts an injected internals factory through the creator', () => { + const fake = fakeInternals() + expect(createWindowsProcessInspector(fake.internals)).toBeInstanceOf(WindowsProcessInspector) + expect(createWindowsProcessInspector()).toBeInstanceOf(WindowsProcessInspector) + }) +}) + +describe('isInvalidHandle', () => { + it('rejects null, zero, and the all-ones INVALID_HANDLE_VALUE forms', () => { + const ptr = (value: bigint): NativePtr => value as NativePtr + expect(isInvalidHandle(null)).toBe(true) + expect(isInvalidHandle(undefined)).toBe(true) + expect(isInvalidHandle(ptr(0n))).toBe(true) + expect(isInvalidHandle(ptr(0xFFFFFFFFFFFFFFFFn))).toBe(true) + expect(isInvalidHandle(ptr(-1n))).toBe(true) + expect(isInvalidHandle(ptr(1234n))).toBe(false) + }) +}) + +const win32 = process.platform === 'win32' ? describe : describe.skip + +win32('WindowsProcessInspector over the real koffi bindings', () => { + it('walks the live process table from the test runner itself', () => { + const inspector = createWindowsProcessInspector() + const tree = inspector.processTree(process.pid) + const self = tree.find(member => member.pid === process.pid) + expect(self).toBeDefined() + expect(inspector.isAlive(self!)).toBe(true) + expect(inspector.foregroundPgid(process.pid)).toBe(process.pid) + }) + + it('reports unreadable identities for absent processes and no-ops tree signalling', () => { + const inspector = createWindowsProcessInspector() + expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false) + expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGKILL')).not.toThrow() + expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGTERM')).not.toThrow() + expect(() => inspector.signalGroup(0, 'SIGKILL')).not.toThrow() + expect(() => inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL')).not.toThrow() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e890bf58c..254839878b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5478,6 +5478,9 @@ importers: packages/pty/pty-local: dependencies: + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -7085,6 +7088,9 @@ importers: packages/subprocess/subprocess-local: dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 node-pty: specifier: ^1.1.0 version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) diff --git a/vitest.config.ts b/vitest.config.ts index c698057915..b730be54a1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,23 +25,26 @@ const windowsUnsupportedPackages = process.platform === 'win32' // INCLUDED: PowerShell ships with Windows, so they run natively here. // This explicit list (not a 'packages/bash/*' glob) keeps // packages/bash/bash — the Service Definition package — running on Windows. + // subprocess-local and pty-local are NOT listed: their win32 branches are + // first-class (Windows inspector, pwsh dialect), so their suites run and + // their sources stay coverage-required on the windows-native lane; the + // bash-requiring tests inside them self-skip through hasBash probes. 'packages/bash/bash-local', 'packages/bash/bash-sandbox', 'packages/bash/tool-bash', 'packages/hooks/*', - 'packages/subprocess/*', - 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', ] : [] -// Windows-only packages: their sources execute exclusively on win32 (koffi -// loads Win32 libraries), so the Linux coverage lane can never cover them. -// The Windows dev/CI lane exercises them through the probe/runner suites; the -// per-file 100% gate must not fail on their Linux-uncovered paths. +// Windows-only sources: they execute exclusively on win32 (koffi loads Win32 +// libraries), so the Linux coverage lane can never cover them. The Windows +// dev/CI lane exercises them through the probe/runner suites; the per-file +// 100% gate must not fail on their Linux-uncovered paths. const windowsOnlyCoverageExclusions = process.platform !== 'win32' ? [ 'packages/sandbox/sandbox-windows-acl/src/**/*.ts', + 'packages/subprocess/subprocess-local/src/windows-inspector.ts', ] : [] From 557c21cd6cc9bd917e786004d3d9fe701b8c845b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:06:42 +0800 Subject: [PATCH 009/180] feat(pty-local): shell dialect for Windows pwsh sessions Adds shellDialect ('bash' | 'pwsh') to the local PTY backend. The effective shellPath/shellArgs resolve per dialect (pwsh through the shared dsh-pwsh-local resolver, bash defaults unchanged), the child environment drops bash-only PS1/PROMPT_COMMAND markers and adds NO_COLOR for pwsh, and pwsh startup bootstraps the prompt function that emits the shared OSC 133;D + BEL marker, waiting (across follow-up sends) until the controlled prompt is actually visible so the banner-to-prompt gap cannot settle startup early. Bash behavior is byte-identical; the real-pwsh suite exercises persistent state and secret scrubbing on Windows. --- packages/e2b/e2b/tests/composition.e2e.ts | 2 +- packages/pty/pty-local/package.json | 1 + packages/pty/pty-local/src/config.ts | 54 ++++++++-- packages/pty/pty-local/src/index.ts | 74 ++++++++++--- packages/pty/pty-local/tests/config.spec.ts | 33 +++++- packages/pty/pty-local/tests/index.spec.ts | 105 ++++++++++++++++++- packages/pty/pty-local/tests/local.spec.ts | 50 ++++++++- packages/pty/pty-local/tests/session.spec.ts | 2 +- packages/pty/pty-local/tsconfig.json | 3 + 9 files changed, 292 insertions(+), 32 deletions(-) diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 76f54ba7fd..33f7e4ca31 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -97,7 +97,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { whenIdle: () => Promise.resolve(), } const backend = new LocalPtyBackend(ctx, { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], rows: 24, cols: 80, scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384, pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000, diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index 3db9e83889..0eb1ee3657 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { diff --git a/packages/pty/pty-local/src/config.ts b/packages/pty/pty-local/src/config.ts index be9ae3eed3..5b7a7c0e9d 100644 --- a/packages/pty/pty-local/src/config.ts +++ b/packages/pty/pty-local/src/config.ts @@ -1,14 +1,20 @@ /** Validated configuration for the local PTY backend. */ import z from '@deepseek-ai/schemastery' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' /** Public plugin configuration. */ export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -37,14 +43,43 @@ export interface Config { disposeGraceMs?: number } -/** Configuration after Schemastery defaults. */ -export type ResolvedConfig = Required +/** Configuration after Schemastery defaults and dialect resolution. */ +export type ResolvedConfig = Omit, 'shellDialect' | 'shellPath' | 'shellArgs'> & { + shellDialect: ShellDialect + shellPath: string + shellArgs: string[] +} + +/** Bash dialect default executable. */ +export const DEFAULT_BASH_SHELL = '/bin/bash' +/** Bash dialect default arguments (interactive, profile-free). */ +export const DEFAULT_BASH_ARGS = ['--noprofile', '--norc', '-i'] +/** Pwsh dialect default arguments (interactive host, profile-free). */ +export const DEFAULT_PWSH_ARGS = ['-NoLogo', '-NoProfile'] + +/** + * Resolve the effective per-dialect shell specification. Defaulting is this + * explicit step: an unset `shellPath`/`shellArgs` selects the dialect's + * defaults, while an explicit value always wins. + * @param config - Schemastery-resolved plugin configuration. + * @returns the fully resolved configuration. + */ +export function resolveConfig(config: Config): ResolvedConfig { + const shellDialect = config.shellDialect ?? 'bash' + return { + ...(config as Required), + shellDialect, + shellPath: config.shellPath ?? (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL), + shellArgs: config.shellArgs ?? (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS), + } +} /** Schemastery config exposed by the plugin. */ export const Config: z = z.object({ backendType: z.string().default('shell'), - shellPath: z.string().default('/bin/bash'), - shellArgs: z.array(z.string()).default(['--noprofile', '--norc', '-i']), + shellDialect: z.union(['bash', 'pwsh'] as const).default('bash'), + shellPath: z.string().required(false), + shellArgs: z.array(z.string()).required(false), rows: z.number().default(40), cols: z.number().default(160), scrollbackLines: z.number().default(10_000), @@ -59,12 +94,11 @@ export const Config: z = z.object({ }) /** - * Assert every numeric config field is a positive safe integer and bounds compose. + * Assert every effective numeric config field is a positive safe integer and bounds compose. * @param config - Schemastery-resolved plugin configuration. - * @returns Narrows the input to the fully resolved configuration. */ -export function validateConfig(config: Config): asserts config is ResolvedConfig { - const resolved = config as ResolvedConfig +export function validateConfig(config: Config): void { + const resolved = resolveConfig(config) if (resolved.backendType.length === 0) throw new Error('pty-local: backendType must be non-empty') if (resolved.shellPath.length === 0) throw new Error('pty-local: shellPath must be non-empty') for (const [name, value] of Object.entries(resolved)) { diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index ee48a5821d..bb1ec520a9 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -12,7 +12,7 @@ import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' -import { type Config, type ResolvedConfig, validateConfig } from './config.ts' +import { type Config, type ResolvedConfig, resolveConfig, type ShellDialect, validateConfig } from './config.ts' import { LocalPtySession } from './session.ts' import { CONTROLLED_PROMPT } from './sanitize.ts' @@ -52,22 +52,39 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { }, { global: true }) } -function childEnvironment(spec: PtyBackendSpawnSpec): Record { +function childEnvironment(spec: PtyBackendSpawnSpec, dialect: ShellDialect): Record { // The subprocess provider supplies its own scrubbed ambient base; these are // deliberate terminal-specific overrides layered after it. - return { + const common = { TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', - PS1: CONTROLLED_PROMPT, - PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"', - BASH_SILENCE_DEPRECATION_WARNING: '1', DSH_SHELL: '1', DSH_SESSION_ID: spec.owner.id, DSH_PTY_SESSION_ID: spec.sessionId, } + if (dialect === 'pwsh') { + // pwsh ignores PS1/PROMPT_COMMAND; its prompt is installed by the startup + // bootstrap instead, and NO_COLOR keeps the renderer quiet. + return { ...common, NO_COLOR: '1' } + } + return { + ...common, + PS1: CONTROLLED_PROMPT, + PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"', + BASH_SILENCE_DEPRECATION_WARNING: '1', + } } +/** + * The pwsh prompt function that emits the shared OSC `133;D;` + BEL marker + * before every prompt, mirroring bash's PROMPT_COMMAND. `[char]27`/`[char]7` + * build the control bytes at runtime because raw ESC characters in submitted + * input are unreliable under PSReadLine. + */ +export const PWSH_PROMPT_SETUP = + "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + CONTROLLED_PROMPT + "' }" + function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] { const argv = [config.shellPath, ...config.shellArgs] if (policy.mode === 'danger-full-access') return argv @@ -82,9 +99,42 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutio // TODO(pty-initialize-race-home): Fold this outer abort race into // LocalPtySession.initialize when the send-state consolidation lands; the // session already owns the send lifecycle the race protects. -async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise { +async function startupSession( + session: LocalPtySession, + dialect: ShellDialect, + signal?: AbortSignal, +): Promise { + const start = async (): Promise => { + if (dialect === 'bash') { + await session.initialize(signal) + return + } + // pwsh cannot install its prompt from the environment: write the prompt + // function through the session and wait for the first marker prompt, + // which is also the readiness contract of the bash initialize path. The + // banner-to-prompt gap can outlast the silence bound, so the wait loops + // over follow-up sends until the controlled prompt is actually visible + // (in the viewport or the retained scrollback when it landed between + // sends), bounded by the send deadline. + let viewport = '' + for (;;) { + const first = viewport.length === 0 + const operation = session.startSend({ + text: first ? PWSH_PROMPT_SETUP : '', + submit: first, + ...signal !== undefined ? { signal } : {}, + }) + const result = await operation.done + if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') + if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') + viewport = result.viewport + const scrollback = session.read({ offset: 0, count: 20 }).text + if (viewport.includes(CONTROLLED_PROMPT) || scrollback.includes(CONTROLLED_PROMPT)) break + } + session.motd = viewport + } if (signal === undefined) { - await session.initialize(signal) + await start() return } const aborted = Promise.withResolvers() @@ -92,7 +142,7 @@ async function initializeSession(session: LocalPtySession, signal?: AbortSignal) signal.addEventListener('abort', onAbort, { once: true }) try { signal.throwIfAborted() - await Promise.race([session.initialize(signal), aborted.promise]) + await Promise.race([start(), aborted.promise]) } finally { signal.removeEventListener('abort', onAbort) } @@ -125,7 +175,7 @@ export class LocalPtyBackend implements PtyBackend { const terminal = await this.spawnTerminal({ argv, cwd: spec.cwd ?? policy.workspaceRoot, - env: childEnvironment(spec), + env: childEnvironment(spec, this.config.shellDialect), rows: this.config.rows, cols: this.config.cols, graceMs: this.config.disposeGraceMs, @@ -133,7 +183,7 @@ export class LocalPtyBackend implements PtyBackend { }) const session = this.createSession(terminal, this.config) try { - await initializeSession(session, spec.signal) + await startupSession(session, this.config.shellDialect, spec.signal) return session } catch (error) { try { @@ -149,5 +199,5 @@ export class LocalPtyBackend implements PtyBackend { /** Register the local PTY backend. */ export function apply(ctx: Context, config: Config): void { validateConfig(config) - ctx.pty.registerBackend(new LocalPtyBackend(ctx, config)) + ctx.pty.registerBackend(new LocalPtyBackend(ctx, resolveConfig(config))) } diff --git a/packages/pty/pty-local/tests/config.spec.ts b/packages/pty/pty-local/tests/config.spec.ts index 87b479c22c..29ccf6b72b 100644 --- a/packages/pty/pty-local/tests/config.spec.ts +++ b/packages/pty/pty-local/tests/config.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import type { Config } from '@deepseek-ai/dsh-pty-local/src/config.ts' -import { validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts' +import { resolveConfig, validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts' function config(overrides: Partial = {}): Config { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160, scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, handoffGraceMs: 50, timeoutMs: 1000, disposeGraceMs: 100, @@ -30,3 +30,32 @@ describe('pty-local config', () => { expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow() }) }) + +describe('pty-local dialect resolution', () => { + it('defaults bash argv to the interactive profile-free form', () => { + const { shellPath, shellArgs, shellDialect } = resolveConfig({ backendType: 'shell', rows: 24, cols: 80 }) + expect(shellDialect).toBe('bash') + expect(shellPath).toBe('/bin/bash') + expect(shellArgs).toEqual(['--noprofile', '--norc', '-i']) + }) + + it('defaults pwsh argv to the interactive profile-free form and resolves the executable', () => { + const resolved = resolveConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 }) + expect(resolved.shellDialect).toBe('pwsh') + expect(resolved.shellPath.length).toBeGreaterThan(0) + expect(resolved.shellArgs).toEqual(['-NoLogo', '-NoProfile']) + }) + + it('lets an explicit shell specification win over the dialect defaults', () => { + const resolved = resolveConfig({ + backendType: 'shell', shellDialect: 'pwsh', shellPath: '/custom/pwsh', shellArgs: ['-NoProfile'], rows: 24, cols: 80, + }) + expect(resolved.shellPath).toBe('/custom/pwsh') + expect(resolved.shellArgs).toEqual(['-NoProfile']) + }) + + it('validates the effective shell path, not only the raw one', () => { + expect(() => { validateConfig({ backendType: 'shell', shellDialect: 'bash', rows: 24, cols: 80 }) }).not.toThrow() + expect(() => { validateConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 }) }).not.toThrow() + }) +}) diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index fc07fc784c..318497670a 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { PassThrough } from 'node:stream' +import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -8,7 +9,8 @@ import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/dsh-pty' -import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local' +import type { PtySendRequest, PtyWaitReason } from '@deepseek-ai/dsh-pty' +import { LocalPtyBackend, PWSH_PROMPT_SETUP } from '@deepseek-ai/dsh-pty-local' import * as ptyLocal from '@deepseek-ai/dsh-pty-local' import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts' import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts' @@ -37,7 +39,7 @@ class RecordingSandbox extends SandboxProvider { function config(): ResolvedConfig { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100, disposeGraceMs: 10, @@ -215,7 +217,7 @@ describe('LocalPtyBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/workspace') }, }]) }) @@ -243,11 +245,11 @@ describe('LocalPtyBackend startup rollback', () => { expect(spawned).toMatchObject({ argv: ['/sandbox', '--', '/bin/bash', '-i'], - cwd: '/session-workspace', + cwd: resolve('/session-workspace'), }) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/session-workspace') }, }]) }) @@ -337,6 +339,99 @@ describe('LocalPtyBackend startup rollback', () => { expect(session.motd).toBe('dsh> ') await session.close('test complete') }) + + it('bootstraps a pwsh dialect through the prompt function and scrubs bash-only env', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + let spawned: SubprocessTerminalSpawnSpec | undefined + let sent: PtySendRequest | undefined + const session = { + motd: '', + startSend: (request: PtySendRequest) => { + sent = request + return { + done: Promise.resolve({ + viewport: 'setup-echo dsh> ', waitReason: 'stdin_read' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new LocalPtyBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async (spec) => { spawned = spec; return terminalHandle() }, + () => session, + ) + expect(await backend.spawn(spec(agent(ctx)))).toBe(session) + expect(sent).toMatchObject({ text: PWSH_PROMPT_SETUP, submit: true }) + expect(session.motd).toBe('setup-echo dsh> ') + expect(spawned?.env).toMatchObject({ + TERM: 'dumb', NO_COLOR: '1', DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1', + }) + expect(spawned?.env?.PS1).toBeUndefined() + expect(spawned?.env?.PROMPT_COMMAND).toBeUndefined() + }) + + it('keeps waiting for the marker prompt when the first send settles on silence', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sends: PtySendRequest[] = [] + const session = { + motd: '', + startSend: (request: PtySendRequest) => { + sends.push(request) + const second = sends.length > 1 + return { + done: Promise.resolve({ + viewport: second ? 'dsh> ' : 'PowerShell 7.6.4\n', + waitReason: 'inferred_idle' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new LocalPtyBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async () => terminalHandle(), + () => session, + ) + await backend.spawn(spec(agent(ctx))) + expect(sends).toHaveLength(2) + expect(sends[1]).toMatchObject({ text: '', submit: false }) + expect(session.motd).toBe('dsh> ') + }) + + it('rejects a pwsh bootstrap whose shell exits or times out', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sessionFor = (waitReason: PtyWaitReason): LocalPtySession => ({ + startSend: () => ({ + done: Promise.resolve({ + viewport: 'no-prompt', waitReason, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + }), + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + close: () => Promise.resolve(), + }) as unknown as LocalPtySession + const exited = new LocalPtyBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('session_exit')) + await expect(exited.spawn(spec(agent(ctx)))).rejects.toThrow('PTY shell exited during startup') + const timedOut = new LocalPtyBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout')) + await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') + }) }) describe('pty-local plugin shape', () => { diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index f23a6f077a..788669b8f9 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -1,4 +1,5 @@ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -12,6 +13,7 @@ import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' import * as ptyLocal from '@deepseek-ai/dsh-pty-local' const roots: string[] = [] @@ -49,6 +51,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { async function harness( mode: 'danger-full-access' | 'workspace-write', timing: { idleSilenceMs?: number; handoffGraceMs?: number; timeoutMs?: number } = {}, + dialect: 'bash' | 'pwsh' = 'bash', ) { const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-')) roots.push(root) @@ -60,6 +63,7 @@ async function harness( await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root }) await ctx.plugin(LocalSubprocessService) const fiber = await ctx.plugin(ptyLocal, { + shellDialect: dialect, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: timing.idleSilenceMs ?? 250, @@ -114,7 +118,9 @@ function processIsRunning(pid: number): boolean { } } -describe('pty-local real shell', () => { +// The real-shell suite drives a POSIX bash over the actual node-pty terminal; +// Windows has no bash, and its pwsh counterpart lives in the describe below. +describe.skipIf(process.platform === 'win32')('pty-local real shell', () => { it('persists cwd and environment across sends, scrubs secrets, and closes', async () => { const previous = process.env.DSH_TEST_SECRET process.env.DSH_TEST_SECRET = 'must-not-leak' @@ -247,3 +253,45 @@ describe('pty-local real shell', () => { await ctx.pty.kill(agent, created.sessionId) }, 35_000) }) + +const hasPwsh = spawnSync( + resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], + { encoding: 'utf8' }, +).status === 0 + +describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { + it('bootstraps a persistent pwsh, persists state, and scrubs secrets', async () => { + const previous = process.env.DSH_TEST_SECRET + process.env.DSH_TEST_SECRET = 'must-not-leak' + try { + const { ctx, root, agent } = await harness('danger-full-access', { + idleSilenceMs: 300, + handoffGraceMs: 300, + timeoutMs: 8_000, + }, 'pwsh') + const created = await ctx.pty.spawn(agent, { type: 'shell', name: 'main', cwd: root }) + expect(created.motd).toContain('dsh> ') + + const first = ctx.pty.startSend(agent, created.sessionId, { + text: '$env:KEEP = "ok"; Set-Location /', + submit: true, + }) + expect((await first.done).waitReason).toBe('stdin_read') + const second = ctx.pty.startSend(agent, created.sessionId, { + text: 'Write-Output "keep=$env:KEEP secret=$env:DSH_TEST_SECRET"', + submit: true, + }) + const result = await second.done + expect(result.viewport).toContain('keep=ok') + expect(result.viewport).toContain('secret=') + expect(result.viewport).not.toContain('must-not-leak') + + expect(ctx.pty.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok') + expect(await ctx.pty.kill(agent, created.sessionId)).toBe(true) + expect(ctx.pty.list(agent)).toEqual([]) + } finally { + if (previous === undefined) delete process.env.DSH_TEST_SECRET + else process.env.DSH_TEST_SECRET = previous + } + }, 30_000) +}) diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 0ca1835e53..e34decd517 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -130,7 +130,7 @@ function makeSession( function config(overrides: Partial = {}): ResolvedConfig { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100, disposeGraceMs: 20, diff --git a/packages/pty/pty-local/tsconfig.json b/packages/pty/pty-local/tsconfig.json index 1580e4c8a2..1a49f5ee67 100644 --- a/packages/pty/pty-local/tsconfig.json +++ b/packages/pty/pty-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../bash/pwsh-local" + }, { "path": "../../core/agent" }, From 0441312768ae04fdaad4c408aaa6be915e814418 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:15:27 +0800 Subject: [PATCH 010/180] feat(pty): persistent pwsh tool and the minimal-preset Windows stack Adds @deepseek-ai/dsh-tool-pwsh-persistent, the mirror of tool-bash-persistent for PowerShell: one owner-scoped persistent pwsh per agent, an Invoke-Expression wrapper with backtick-escaped bodies and exact native exit codes ( reset, \True fallback, catch to 1), PSReadLine-echo tolerance (the echoed wrapper is stripped from captured output and can never fabricate completion), and the same timeout/cancel/exit reset semantics with pwsh-flavored diagnostics. The minimal preset now gates its persistent shell stack by platform with the #2234 disabled interpolation: the bash rows mount on POSIX and the pwsh rows (pty-local shellDialect pwsh + the new tool) on win32, keeping exactly one persistent shell per host. windows-shell.spec pins the per-platform roster; the real Loader composition proves cwd/env persistence, multiline and here-string commands, large-output clipping, and exit/reset over a real ConPTY pwsh. --- .../agent-presets/minimal/agent.cordis.yml | 25 + apps/cli/package.json | 1 + apps/cli/tests/windows-shell.spec.ts | 23 +- .../pty/tool-pwsh-persistent/package.json | 62 ++ .../pty/tool-pwsh-persistent/src/index.ts | 476 ++++++++++++++ .../pty/tool-pwsh-persistent/src/invariant.ts | 31 + .../tests/loader-composition.spec.ts | 167 +++++ .../tool-pwsh-persistent/tests/tools.spec.ts | 594 ++++++++++++++++++ .../pty/tool-pwsh-persistent/tsconfig.json | 17 + pnpm-lock.yaml | 58 ++ tsconfig.host.json | 1 + 11 files changed, 1454 insertions(+), 1 deletion(-) create mode 100644 packages/pty/tool-pwsh-persistent/package.json create mode 100644 packages/pty/tool-pwsh-persistent/src/index.ts create mode 100644 packages/pty/tool-pwsh-persistent/src/invariant.ts create mode 100644 packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts create mode 100644 packages/pty/tool-pwsh-persistent/tests/tools.spec.ts create mode 100644 packages/pty/tool-pwsh-persistent/tsconfig.json diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 1ec0a6ea75..cce25d41b8 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -13,6 +13,8 @@ # 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. +# Exactly one shell stack mounts per host: the bash stack gates off win32 and +# its pwsh twin gates off POSIX, mirroring the one-shot shell rows. - id: persistent-shell name: cordis:group group: true @@ -24,11 +26,13 @@ - id: pty-local name: '@deepseek-ai/dsh-pty-local' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 - id: persistent-bash name: '@deepseek-ai/dsh-tool-bash-persistent' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 description: |- @@ -41,6 +45,27 @@ * 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: pty-pwsh + name: '@deepseek-ai/dsh-pty-local' + disabled: !!js process.platform !== 'win32' + config: + shellDialect: pwsh + timeoutMs: 300000 + + - id: persistent-pwsh + name: '@deepseek-ai/dsh-tool-pwsh-persistent' + disabled: !!js process.platform !== 'win32' + config: + timeoutMs: 300000 + description: |- + Run commands in a PowerShell 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. + * State is persistent across command calls and discussions with the user. + * Use native Windows paths (C:\...) and $env:NAME variables; this is PowerShell, not bash. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process. + # The editor requires absolute paths unconditionally. - id: str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/apps/cli/package.json b/apps/cli/package.json index 694732fe72..4f7758c722 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 5898314e65..b4202c0dc3 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -122,7 +122,7 @@ describe('shipped agent presets gate both shell tools by platform', () => { } }) - it('minimal mounts no shell tool row at all (its shell is the PTY stack)', () => { + it('minimal mounts no shell tool row and gates its persistent shell stack by platform', () => { const entries: unknown = yaml.load( readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'), { schema: entryListSchema }, @@ -133,5 +133,26 @@ describe('shipped agent presets gate both shell tools by platform', () => { typeof entry === 'object' && entry !== null && (entry as Record).id === id )), `${id} must be absent from minimal`).toBe(false) } + const group = entries.find((entry): entry is Record => ( + typeof entry === 'object' && entry !== null && (entry as Record).id === 'persistent-shell' + )) + if (group === undefined) throw new TypeError('minimal preset must mount persistent-shell') + const rows = group.config as unknown[] + if (!Array.isArray(rows)) throw new TypeError('persistent-shell must carry a row list') + const byId = new Map(rows + .filter((entry): entry is Record => typeof entry === 'object' && entry !== null) + .map(entry => [entry.id, entry])) + // The bash stack (pty-local + persistent-bash) mounts on POSIX only; the + // pwsh twin (pty-local with shellDialect pwsh + persistent-pwsh) mounts on + // win32 only — exactly one persistent shell per host. + for (const id of ['pty-local', 'persistent-bash']) { + expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(true) + expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(false) + } + for (const id of ['pty-pwsh', 'persistent-pwsh']) { + expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(false) + expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(true) + } + expect(byId.get('pty-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' }) }) }) diff --git a/packages/pty/tool-pwsh-persistent/package.json b/packages/pty/tool-pwsh-persistent/package.json new file mode 100644 index 0000000000..1cf2e224d0 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/package.json @@ -0,0 +1,62 @@ +{ + "name": "@deepseek-ai/dsh-tool-pwsh-persistent", + "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/pty/tool-pwsh-persistent" + }, + "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" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@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": "workspace:^" + }, + "devDependencies": { + "@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:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/pty/tool-pwsh-persistent/src/index.ts b/packages/pty/tool-pwsh-persistent/src/index.ts new file mode 100644 index 0000000000..53c495585d --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/src/index.ts @@ -0,0 +1,476 @@ +/** + * Model-facing persistent `pwsh` tool over the owner-scoped PTY seam. + * @module @deepseek-ai/dsh-tool-pwsh-persistent + */ + +import { randomUUID } from 'node:crypto' +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' +import { defineTool } from '@deepseek-ai/dsh-tools' + +// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. +const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with Select-String in order to find the line numbers of what you are looking for.' +const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' +const SHELL_RESET_MESSAGE = 'The persistent pwsh shell was reset; the next pwsh call starts from the workspace with a fresh current directory and environment.' +const SHELL_PROMPT = '__DSH_PERSISTENT_PWSH_PROMPT__ ' +const TIMEOUT_CODE = 'PERSISTENT_PWSH_TIMEOUT' +// One page is enough to find a just-emitted completion marker; the full +// scrollback is assembled only when a command settles or needs partial output. +const SCROLLBACK_PAGE_LINES = 1_000 +const POLL_INTERVAL_MS = 25 + +const DEFAULT_DESCRIPTION = 'Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.' + +interface ResolvedConfig { + backendType: string + timeoutMs: number + maxOutputChars: number + description: string +} + +interface CommandMarkers { + start: string + end: string +} + +interface RetainedOutput { + text: string + truncated: boolean +} + +interface CapturedOutput { + text: string + incomplete: boolean + exitCode?: number +} + +interface PersistentShells { + get(owner: Agent, signal: AbortSignal): Promise + reset(owner: Agent, reason: string): Promise +} + +function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string { + if (content.length <= maxOutputChars && !incomplete) return content + return content.length <= maxOutputChars + ? content + TRUNCATED_MESSAGE + : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE +} + +function markers(): CommandMarkers { + const nonce = randomUUID() + return { + start: `__DSH_PERSISTENT_PWSH_START_${nonce}__`, + end: `__DSH_PERSISTENT_PWSH_END_${nonce}:`, + } +} + +/** + * Escape a command body for embedding in the wrapper's double-quoted string. + * Backtick escapes keep every character literal: backtick first so the + * escapes this function inserts are never re-escaped, `$` so no expansion + * happens at wrapper construction, and `\r\n`/ESC so multi-line commands and + * raw control bytes ride one physical input line without PSReadLine mangling. + * @param value - the model's PowerShell command text. + * @returns the escaped double-quoted-string body. + */ +function quoteForPwsh(value: string): string { + return value + .replaceAll('`', '``') + .replaceAll('"', '`"') + .replaceAll('$', '`$') + .replaceAll('\r', '') + .replaceAll('\n', '`n') + .replaceAll('\x1b', '`e') +} + +function wrapCommand(command: string, marker: CommandMarkers): string { + // Keep the wrapper on one physical line: PSReadLine renders the echoed + // input, and a wrapped line would split the echo the extraction strips. + // The echoed END nonce can never fabricate completion because the status + // regex needs digits immediately after it and the echo continues with + // quote characters. + const body = quoteForPwsh(command) + return `Write-Output '${marker.start}'; $LASTEXITCODE = $null; $__s = 1; try { Invoke-Expression "${body}"; $__ok = $? } catch { $__ok = $false }; if ($null -ne $LASTEXITCODE) { $__s = [int]$LASTEXITCODE } else { $__s = if ($__ok) { 0 } else { 1 } }; Write-Output ('${marker.end}' + $__s)` +} + +function stripPrompt(text: string): string { + let result = text.replace(/\r?\n$/, '') + while (result.endsWith(SHELL_PROMPT)) { + result = result.slice(0, -SHELL_PROMPT.length) + } + return result.endsWith('\n') ? result.slice(0, -1) : result +} + +function commandOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, + wrapper: string, +): CapturedOutput | undefined { + const text = snapshot.text + const end = text.lastIndexOf(marker.end) + const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1] + if (status === undefined) return undefined + const startMarker = text.lastIndexOf(marker.start, end) + const start = startMarker < 0 ? 0 : startMarker + marker.start.length + let captured = text.slice(start, end) + // The PSReadLine echo carries the wrapper source (including both marker + // nonces) before the real markers; anchor on the real markers excludes it, + // and stripping the wrapper covers the rare case where the real START + // scrolled out and extraction fell back to the echoed copy. + captured = captured.replaceAll(wrapper, '') + return { + text: stripPrompt(captured.replace(/^\r?\n/, '')), + incomplete: startMarker < 0, + exitCode: Number(status), + } +} + +function promptCompleted(result: PtySendResult): boolean { + return result.viewport.endsWith(SHELL_PROMPT) + || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`) + || result.viewport.endsWith(`${SHELL_PROMPT}\n`) +} + +function partialOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, + wrapper: string, + fallback: string, + fallbackTruncated = false, +): CapturedOutput { + const startMarker = snapshot.text.lastIndexOf(marker.start) + if (startMarker >= 0) { + return { + text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), + incomplete: false, + } + } + const fallbackStart = fallback.lastIndexOf(marker.start) + const afterStart = fallbackStart < 0 + ? fallback + : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '') + const fallbackEnd = afterStart.lastIndexOf(marker.end) + const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd) + return { + text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '').replaceAll(wrapper, '')), + incomplete: fallbackTruncated || fallbackStart < 0, + } +} + +async function pause(): Promise { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) +} + +function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { + if (page.text.length === 0 || page.lineEnd <= offset) return undefined + return page.lineEnd +} + +function retainedScrollback( + ctx: Context, + owner: Agent, + id: PtySessionId, + latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), +): RetainedOutput { + const pages: string[] = latest.text.length === 0 ? [] : [latest.text] + let offset = latest.lineEnd + let truncated = latest.truncated + while (true) { + if (offset >= latest.totalLines) break + const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) + truncated ||= page.truncated + if (page.text.length > 0) pages.unshift(page.text) + const next = nextScrollbackOffset(page, offset) + if (next === undefined || next >= page.totalLines) break + offset = next + } + return { text: pages.join('\n'), truncated } +} + +function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { + const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) + const withPrefix = output.incomplete && output.text.length > 0 + ? LOST_PREFIX_MESSAGE + rendered + : rendered + const marker = output.exitCode !== undefined && output.exitCode !== 0 + ? `[exit code: ${output.exitCode}]` + : undefined + return appendStatusMarker(withPrefix, marker) +} + +function appendStatusMarker(content: string, marker: string | undefined): string { + if (marker === undefined) return content + return content.length === 0 ? marker : `${content}\n${marker}` +} + +function renderShellExitStatus( + content: string, + exitCode: number | null, + signal: NodeJS.Signals | null, +): string { + const marker = signal !== null + ? `[shell killed by signal: ${signal}]` + : exitCode !== null + ? `[shell exited: code ${exitCode}]` + : '[shell exited]' + return appendStatusMarker(content, marker) +} + +/** + * The pwsh prompt function that overrides the backend bootstrap value with + * this tool's own prompt. `[char]27`/`[char]7` build the OSC bytes at runtime + * because raw ESC characters in submitted input are unreliable under + * PSReadLine. + */ +const PWSH_PROMPT_SETUP = + "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + SHELL_PROMPT + "' }" + +function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { + const pending = new WeakMap>() + const live = new Map() + const creating = new Set>() + const ownerCleanupInstalled = new WeakSet() + const lifecycle = new AbortController() + + const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { + if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return + await ctx.pty.kill(owner, id, reason) + } + + ctx.effect(() => async () => { + lifecycle.abort(new Error('tool-pwsh-persistent disposed during shell creation')) + await Promise.allSettled([...creating]) + const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-pwsh-persistent disposed') }) + await Promise.all(closing) + live.clear() + }, 'tool-pwsh-persistent shell cleanup') + + const reset = async (owner: Agent, reason: string): Promise => { + pending.delete(owner) + const id = live.get(owner) + live.delete(owner) + if (id !== undefined) await close(owner, id, reason) + } + + const get = (owner: Agent, signal: AbortSignal): Promise => { + const existing = pending.get(owner) + if (existing !== undefined) return existing + const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) + const creation = (async () => { + try { + const cwd = owner.session.header.cwd + const spawned = await ctx.pty.spawn(owner, { + type: config.backendType, + ...cwd === undefined ? {} : { cwd }, + }, combinedSignal) + live.set(owner, spawned.sessionId) + if (!ownerCleanupInstalled.has(owner)) { + ownerCleanupInstalled.add(owner) + owner.ctx.effect(() => () => { + pending.delete(owner) + live.delete(owner) + }, 'tool-pwsh-persistent owner cache cleanup') + } + const setup = ctx.pty.startSend(owner, spawned.sessionId, { + text: PWSH_PROMPT_SETUP, + submit: true, + signal: combinedSignal, + }) + const result = await setup.done + if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { + throw new Error('persistent pwsh shell did not accept initialization') + } + return spawned.sessionId + } catch (error: unknown) { + await reset(owner, 'persistent pwsh initialization failed') + throw error + } + })() + const tracked = creation.finally(() => { + creating.delete(tracked) + }) + creating.add(tracked) + pending.set(owner, tracked) + return tracked + } + + return { get, reset } +} + +async function executeCommand( + ctx: Context, + shells: PersistentShells, + owner: Agent, + command: string, + config: ResolvedConfig, + upstream: AbortSignal, +): Promise { + using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE) + const id = await shells.get(owner, commandDeadline.signal) + const marker = markers() + const wrapped = wrapCommand(command, marker) + let first = true + let fallback = '' + let fallbackTruncated = false + + while (true) { + let operation + let result + try { + operation = ctx.pty.startSend(owner, id, { + text: first ? wrapped : '', + submit: first, + signal: commandDeadline.signal, + }) + first = false + result = await operation.done + } catch (error: unknown) { + await shells.reset(owner, 'persistent pwsh send failed') + throw error + } + const incremental = operation.readOutput() + fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport + fallbackTruncated ||= incremental.truncated || result.truncated + const latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) + const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) + if (timedOut !== undefined) { + const snapshot = retainedScrollback(ctx, owner, id, latest) + const partial = renderCaptured( + partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), + config.maxOutputChars, + ) + await shells.reset(owner, 'persistent pwsh command timed out') + return [ + // TODO: Report a timeout only; this signal does not establish an OOM. + `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`, + partial, + SHELL_RESET_MESSAGE, + ].join('\n') + } + if (commandDeadline.signal.aborted) { + await shells.reset(owner, 'persistent pwsh command aborted') + commandDeadline.signal.throwIfAborted() + } + if (latest.text.includes(marker.end)) { + const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker, wrapped) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + } + if (result.sessionStatus.kind === 'exited') { + const snapshot = retainedScrollback(ctx, owner, id, latest) + await shells.reset(owner, 'persistent pwsh shell exited') + return [ + renderShellExitStatus( + renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars), + result.sessionStatus.exitCode, + result.sessionStatus.signal, + ), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') + } + if (promptCompleted(result)) { + const snapshot = retainedScrollback(ctx, owner, id, latest) + return renderCaptured( + partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), + config.maxOutputChars, + ) + } + await pause() + } +} + +/** + * Register the model-facing persistent `pwsh` tool. + * @param ctx - plugin context carrying tools and the owner-scoped PTY service. + * @param config - selected PTY backend and command deadline. + */ +function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void { + const shells = persistentShells(ctx, config) + const queues = new WeakMap>() + + const serialized = async (owner: Agent, operation: () => Promise): Promise => { + const prior = queues.get(owner) ?? Promise.resolve() + const run = prior.then(operation, operation) + const tail = run.then(() => undefined, () => undefined) + queues.set(owner, tail) + try { + return await run + } finally { + if (queues.get(owner) === tail) queues.delete(owner) + } + } + + ctx.tools.register(defineTool({ + name: 'pwsh', + description: config.description, + parameters: { + command: { + type: 'string', + required: true, + description: 'The PowerShell command to run. Relative path is preferred in the command.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args, exec) { + if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + const owner = exec.agent + if (owner === undefined) throw new Error('pwsh requires an owning agent session') + return serialized(owner, async () => { + exec.signal.throwIfAborted() + return executeCommand(ctx, shells, owner, args.command, config, exec.signal) + }) + }, + presentCall: args => ({ card: 'terminal', title: args.command }), + })) +} + +export const name = 'tool-pwsh-persistent' +export const inject = ['tools', 'pty'] + +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} + +/** Runtime configuration schema for the persistent pwsh tool. */ +export const Config: z = z.object({ + backendType: z.string().default('shell'), + timeoutMs: z.number().default(300_000), + maxOutputChars: z.number().default(16_000), + description: z.string().default(DEFAULT_DESCRIPTION), +}) + +/** Register one owner-scoped persistent `pwsh` tool. */ +export function apply(ctx: Context, config: Config): void { + const resolved: ResolvedConfig = { + backendType: config.backendType ?? 'shell', + timeoutMs: config.timeoutMs ?? 300_000, + maxOutputChars: config.maxOutputChars ?? 16_000, + description: config.description ?? DEFAULT_DESCRIPTION, + } + if (resolved.backendType.trim().length === 0) { + throw new Error('tool-pwsh-persistent: backendType must be non-empty') + } + if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) { + throw new Error('tool-pwsh-persistent: timeoutMs must be a positive safe integer') + } + if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { + throw new Error('tool-pwsh-persistent: maxOutputChars must be a positive safe integer') + } + if (resolved.description.trim().length === 0) { + throw new Error('tool-pwsh-persistent: description must be non-empty') + } + registerPersistentPwsh(ctx, resolved) +} diff --git a/packages/pty/tool-pwsh-persistent/src/invariant.ts b/packages/pty/tool-pwsh-persistent/src/invariant.ts new file mode 100644 index 0000000000..6f436019f2 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh-persistent`. + * @module @deepseek-ai/dsh-tool-pwsh-persistent/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh-persistent' + +/** Cordis companion plugin name. */ +export const name = 'tool-pwsh-persistent-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the adapter's private owner-to-shell cache has no + * observable event or data relation. Lifecycle tests prove its cleanup without + * adding a public API solely for an invariant. + */ +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/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..fc10f9cfe3 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -0,0 +1,167 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import 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' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import * as PtyLocal from '@deepseek-ai/dsh-pty-local' +import SandboxProvider from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' + +const hasPwsh = spawnSync( + resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], + { encoding: 'utf8' }, +).status === 0 + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +class PassthroughSandbox extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } + } +} + +function agent(ctx: Context, cwd: string): Agent { + const id = SessionId('persistent-pwsh-loader-agent') + const scope = ctx.plugin(() => {}) + const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) + const value: Agent = { + id, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scope.ctx, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader composition', () => { + it('preserves cwd and environment across calls', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-system-prompt'", + "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-pty'", + "- name: '@deepseek-ai/dsh-test-sandbox'", + "- name: '@deepseek-ai/dsh-sandbox-policy'", + ' config:', + ' mode: danger-full-access', + ` workspaceRoot: ${JSON.stringify(root)}`, + "- name: '@deepseek-ai/dsh-subprocess-local'", + "- name: '@deepseek-ai/dsh-pty-local'", + ' config:', + ' shellDialect: pwsh', + ' pollIntervalMs: 10', + ' exactProbeAfterMs: 20', + ' idleSilenceMs: 300', + ' handoffGraceMs: 300', + ' scrollbackLines: 20000', + ' timeoutMs: 8000', + ' disposeGraceMs: 500', + "- name: '@deepseek-ai/dsh-tool-pwsh-persistent'", + ' config:', + ' timeoutMs: 20000', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-tools', ToolRegistry], + ['@deepseek-ai/dsh-pty', PtyService], + ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], + ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], + ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService], + ['@deepseek-ai/dsh-pty-local', PtyLocal], + ['@deepseek-ai/dsh-tool-pwsh-persistent', ToolPwshPersistent], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await context.loader.await() + + const owner = agent(context, root) + const signal = new AbortController().signal + const execute = (id: string, command: string) => context!.tools.execute({ + signal, + callId: CallId(id), + name: 'pwsh', + arguments: { command }, + agent: owner, + }) + + expect(context.tools.schemas().map(schema => schema.name)).toEqual(['pwsh']) + await execute('state', '$env:KEEP = "loader"; New-Item -ItemType Directory -Force -Path nested | Out-Null; Set-Location nested') + const observed = text(await execute('observe', 'Write-Output "cwd=$PWD keep=$env:KEEP"')) + expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`) + expect(observed).not.toContain('DSH_PERSISTENT_PWSH') + + const multiline = text(await execute( + 'multiline', + '$value = "line one"\nWrite-Output "${value}:it\'s fine"', + )) + expect(multiline).toBe("line one:it's fine") + expect(multiline).not.toContain('DSH_PERSISTENT_PWSH') + + const hereString = text(await execute( + 'here-string', + "$h = @'\nalpha\nbeta\n'@\nWrite-Output $h", + )) + expect(hereString).toBe('alpha\nbeta') + + const large = text(await execute('large-output', '1..12050 | ForEach-Object { $_ }')) + expect(large.startsWith('1\n2\n3\n')).toBe(true) + expect(large).toContain('') + expect(large).not.toContain('beginning of this command output was dropped') + + const exited = text(await execute('exit', 'exit')) + expect(exited).toContain('next pwsh call starts from the workspace') + expect(text(await execute('after-exit', 'Write-Output "$PWD"'))).toBe(root) + }, 60_000) +}) diff --git a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts new file mode 100644 index 0000000000..b1a6ee8395 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts @@ -0,0 +1,594 @@ +import { afterEach, describe, expect, it } from 'vitest' +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' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import type { + PtyBackend, + PtyBackendSession, + PtyReadRequest, + PtySendOperation, + PtySendRequest, + PtySessionStatus, + PtySignal, + PtyWaitReason, +} from '@deepseek-ai/dsh-pty' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' + +const contexts: Context[] = [] +let callNumber = 0 + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +function agent(ctx: Context, cwd: string | undefined): Agent { + const id = SessionId(`persistent-pwsh-owner-${callNumber}`) + const scope = ctx.plugin(() => {}) + const session = Session.create(id, [], { + version: 0, + id, + createdAt: 0, + ...cwd === undefined ? {} : { cwd }, + }) + const value: Agent = { + id, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scope.ctx, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +function call( + ctx: Context, + owner: Agent | undefined, + command: string, + signal = new AbortController().signal, +) { + return ctx.tools.execute({ + signal, + callId: CallId(`persistent-pwsh-${++callNumber}`), + name: 'pwsh', + arguments: { command }, + ...owner === undefined ? {} : { agent: owner }, + }) +} + +type StubMode = + | 'normal' + | 'prompt-only' + | 'prompt-crlf' + | 'empty-read' + | 'stalled-read' + | 'exit' + | 'signal-exit' + | 'unknown-exit' + | 'wait-for-abort' + | 'end-on-abort' + | 'idle-then-normal' + | 'large' + | 'nonzero' + | 'torn-status' + | 'finish-torn-status' + | 'end-only' + | 'init-exit' + | 'init-timeout' + | 'spawn-error' + | 'send-error' + | 'prompt-after-idle' + | 'incremental-fallback' + | 'empty-page-after-latest' + | 'paged-scrollback' + | 'with-echo' + +const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ +const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ + +class StubPtySession implements PtyBackendSession { + readonly motd = '__DSH_PERSISTENT_PWSH_PROMPT__ ' + readonly pid = 123 + statusValue: PtySessionStatus = { kind: 'running' } + scrollback = this.motd + closed: string[] = [] + mode: StubMode + sends = 0 + pendingText = '' + historyTruncated = false + + constructor(mode: StubMode) { + this.mode = mode + } + + startSend(request: PtySendRequest): PtySendOperation { + this.sends += 1 + if (request.text.startsWith('function prompt')) { + if (this.mode === 'init-exit') { + this.statusValue = { kind: 'exited', exitCode: 1, signal: null } + return this.operation(Promise.resolve(this.result('', 'session_exit'))) + } + if (this.mode === 'init-timeout') { + return this.operation(Promise.resolve(this.result('', 'timeout'))) + } + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) + } + if (this.mode === 'send-error') throw new Error('stub send failed') + if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { + const done = new Promise>((resolve) => { + request.signal?.addEventListener('abort', () => { + const start = START_PATTERN.exec(request.text)?.[0] + const end = END_PATTERN.exec(request.text)?.[0] + const output = this.mode === 'end-on-abort' + ? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}` + : 'partial output' + this.scrollback += output + resolve(this.result(output, 'stdin_read')) + }, { once: true }) + }) + return this.operation(done) + } + if (this.mode === 'idle-then-normal') { + this.mode = 'normal' + this.pendingText = request.text + return this.operation(Promise.resolve(this.result('', 'inferred_idle'))) + } + if (this.mode === 'prompt-after-idle') { + if (request.text.length > 0) { + const start = START_PATTERN.exec(request.text)?.[0] + const output = `${start ?? ''}\npartial syntax output\n` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + const output = `pwsh: syntax error\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') { + const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n' + const output = `pwsh: syntax error${newline}${this.motd}${newline}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const sent = request.text.length > 0 ? request.text : this.pendingText + this.pendingText = '' + const start = START_PATTERN.exec(sent)?.[0] + const end = END_PATTERN.exec(sent)?.[0] + if (this.mode === 'with-echo') { + // The PSReadLine echo renders the submitted wrapper before the real + // markers; the tool must strip it from the captured result. + const output = `${sent}\n${start ?? ''}\nhello from stub\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'incremental-fallback') { + const incremental = `${start ?? ''}\nincrement\n${this.motd}` + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) + } + if (this.mode === 'torn-status') { + const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` + this.scrollback += output + this.mode = 'finish-torn-status' + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + if (this.mode === 'finish-torn-status') { + const output = `7\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'end-only') { + const output = `recovered output\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const commandOutput = this.mode === 'large' + ? 'x'.repeat(100) + : this.mode === 'nonzero' ? '' : 'hello from stub' + const exitCode = this.mode === 'nonzero' ? 7 : 0 + const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` + this.scrollback += output + if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') { + const exitedOutput = `${start ?? ''}\nhello from stub\n` + this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput + this.statusValue = this.mode === 'signal-exit' + ? { kind: 'exited', exitCode: null, signal: 'SIGTERM' } + : this.mode === 'exit' + ? { kind: 'exited', exitCode: 9, signal: null } + : { kind: 'exited', exitCode: null, signal: null } + return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) + } + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + + read(request: PtyReadRequest) { + if (this.mode === 'empty-read') { + return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } + } + if (this.mode === 'stalled-read') { + return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false } + } + if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) { + return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false } + } + const lines = this.scrollback.split('\n') + if (this.mode === 'paged-scrollback') { + const offset = request.offset ?? 0 + const end = lines.length - offset + const start = Math.max(0, end - 3) + const returnedLines = end - start + return { + text: lines.slice(start, end).join('\n'), + totalLines: lines.length, + lineBegin: offset, + lineEnd: offset + returnedLines, + truncated: this.historyTruncated, + } + } + return { + text: this.scrollback, + totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length, + lineBegin: 0, + lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length, + truncated: this.historyTruncated, + } + } + + signal(_signal: PtySignal) { + return Promise.resolve({ delivered: true as const, targetPgid: 123 }) + } + + status() { + return this.statusValue + } + + async close(reason: string) { + this.closed.push(reason) + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + } + + private result(viewport: string, waitReason: PtyWaitReason) { + return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } + } + + private operation(done: Promise>, delta = ''): PtySendOperation { + return { + done, + readOutput: () => ({ delta, truncated: false }), + cancel: () => false, + } + } +} + +function stubBackend(initialMode: StubMode = 'normal') { + const sessions: StubPtySession[] = [] + const backend: PtyBackend = { + type: 'stub', + async spawn() { + if (initialMode === 'spawn-error') throw new Error('stub spawn failed') + const session = new StubPtySession(initialMode) + sessions.push(session) + return session + }, + } + return { backend, sessions } +} + +async function setup( + config: ToolPwshPersistent.Config = { backendType: 'stub' }, + initialMode: StubMode = 'normal', +) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const stub = stubBackend(initialMode) + ctx.pty.registerBackend(stub.backend) + const fiber = await ctx.plugin(ToolPwshPersistent, config) + return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } +} + +describe('tool-pwsh-persistent', () => { + it('registers a configurable schema and reuses one owner shell', async () => { + const { ctx, owner, stub, fiber } = await setup({ + backendType: 'stub', + description: 'deployment-specific persistent shell', + }) + const schema = ctx.tools.schemas()[0] + expect(ctx.tools.schemas().map(item => item.name)).toEqual(['pwsh']) + expect(schema?.description).toBe('deployment-specific persistent shell') + expect(schema?.parameters).toMatchObject({ + required: ['command'], + properties: { command: { type: 'string' } }, + }) + expect(ctx.tools.get('pwsh')?.presentCall?.({ command: 'pwd' })) + .toEqual({ card: 'terminal', title: 'pwd' }) + + expect(text(await call(ctx, owner, 'Write-Output one'))).toBe('hello from stub') + expect(text(await call(ctx, owner, 'Write-Output two'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(1) + expect(stub.sessions[0]?.sends).toBe(3) + + const ownerWithoutCwd = agent(ctx, undefined) + expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + + await fiber.dispose() + expect(ctx.tools.schemas()).toEqual([]) + expect(ctx.tools.get('pwsh')).toBeUndefined() + }) + + it('strips the echoed wrapper from captured output', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'with-echo' + const result = text(await call(ctx, owner, 'Write-Output hi')) + expect(result).toBe('hello from stub') + expect(result).not.toContain('__DSH_PERSISTENT_PWSH_START_') + expect(result).not.toContain('__DSH_PERSISTENT_PWSH_END_') + expect(result).not.toContain('Invoke-Expression') + }) + + it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { + const { ctx, owner, stub, fiber } = await setup({ + backendType: 'stub', + maxOutputChars: 10, + }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'idle-then-normal' + expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from') + + session.mode = 'incremental-fallback' + session.scrollback = '' + expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment') + + session.mode = 'prompt-only' + const promptFallback = text(await call(ctx, owner, 'bad {')) + expect(promptFallback).toContain('pwsh: synt') + expect(promptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + + session.mode = 'prompt-crlf' + session.scrollback = '' + const crlfPromptFallback = text(await call(ctx, owner, 'bad {')) + expect(crlfPromptFallback).toContain('pwsh: synt') + expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + + session.mode = 'end-only' + session.scrollback = '' + const missingStart = text(await call(ctx, owner, 'recover marker')) + expect(missingStart).toContain('recovered') + expect(missingStart).toContain('beginning of this command output was dropped') + expect(missingStart).toContain('') + + session.mode = 'large' + expect(text(await call(ctx, owner, 'large'))).toContain('') + + session.mode = 'nonzero' + expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]') + + session.mode = 'exit' + const exited = text(await call(ctx, owner, 'exit')) + expect(exited).toContain('hello from') + expect(exited).toContain('[shell exited: code 9]') + expect(exited).not.toContain('[exit code: 9]') + expect(exited).toContain('next pwsh call starts from the workspace') + expect(session.closed).toContain('persistent pwsh shell exited') + + await call(ctx, owner, 'new shell') + expect(stub.sessions).toHaveLength(2) + const replacement = stub.sessions[1]! + replacement.mode = 'signal-exit' + expect(text(await call(ctx, owner, 'kill shell'))) + .toContain('[shell killed by signal: SIGTERM]') + + await call(ctx, owner, 'another shell') + expect(stub.sessions).toHaveLength(3) + const externallyClosed = ctx.pty.list(owner)[0]?.sessionId + expect(externallyClosed).toBeDefined() + await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') + await fiber.dispose() + expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) + }) + + it('waits for status digits after a torn completion marker', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'torn-status' + stub.sessions[0]!.scrollback = '' + + expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') + }) + + it('reports a shell exit when the backend has no code or signal', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'unknown-exit' + + expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]') + }) + + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'end-only' + session.scrollback = '' + expect(text(await call(ctx, owner, 'missing start'))) + .toContain('beginning of this command output was dropped') + + session.mode = 'empty-read' + expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub') + + session.mode = 'stalled-read' + expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') + + session.mode = 'empty-page-after-latest' + expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub') + }) + + it('assembles retained output across backward scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'paged-scrollback' + session.scrollback = 'older one\nolder two\nolder three\nolder four\n' + + expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub') + }) + + it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'prompt-after-idle' + session.scrollback = '' + const result = text(await call(ctx, owner, 'bad {')) + expect(result).toContain('partial syntax output') + expect(result).toContain('pwsh: syntax error') + expect(result).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + expect(result).not.toContain('DSH_PERSISTENT_PWSH_START') + }) + + it('does not attribute old scrollback truncation to a complete current command', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.historyTruncated = true + const result = text(await call(ctx, owner, 'short command')) + expect(result).toBe('hello from stub') + expect(result).not.toContain('') + expect(result).not.toContain('beginning of this command output was dropped') + }) + + it('closes a timed-out shell and reports bounded partial output', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'wait-for-abort' + const result = await call(ctx, owner, 'hang') + expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error') + expect(text(result)).toContain('partial output') + expect(text(result)).toContain('next pwsh call starts from the workspace') + expect(stub.sessions[0]?.closed).toContain('persistent pwsh command timed out') + }) + + it.each(['wait-for-abort', 'end-on-abort'] as const)( + 'cancels %s work, resets the shell, and releases a queued call', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = mode + const controller = new AbortController() + const cancelled = call(ctx, owner, 'hang', controller.signal) + const queued = call(ctx, owner, 'after cancellation') + setTimeout(() => { + controller.abort(new Error('caller stopped')) + }, 5) + + expect((await cancelled).isError).toBe(true) + expect(text(await queued)).toBe('hello from stub') + expect(stub.sessions[0]?.closed).toContain('persistent pwsh command aborted') + expect(stub.sessions).toHaveLength(2) + }, + ) + + it.each(['init-exit', 'init-timeout'] as const)( + 'fails initialization and closes the unusable shell for %s', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode) + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent pwsh initialization failed') + }, + ) + + it('clears a failed spawn without trying to close an unpublished shell', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error') + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + }) + + it('resets a cached shell after startSend fails', async () => { + const { ctx, owner, stub } = await setup() + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'send-error' + expect((await call(ctx, owner, 'fails')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent pwsh send failed') + expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + + it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const spawnStarted = Promise.withResolvers() + const spawnAborted = Promise.withResolvers() + ctx.pty.registerBackend({ + type: 'slow', + spawn: spec => new Promise((_resolve, reject) => { + spawnStarted.resolve(undefined) + spec.signal?.addEventListener('abort', () => { + spawnAborted.resolve(undefined) + const reason: unknown = spec.signal?.reason + reject(reason instanceof Error + ? reason + : new Error('slow PTY spawn aborted', { cause: reason })) + }, { once: true }) + }), + }) + const fiber = await ctx.plugin(ToolPwshPersistent, { backendType: 'slow' }) + const owner = agent(ctx, '/workspace') + const running = call(ctx, owner, 'pwd') + await spawnStarted.promise + await fiber.dispose() + await spawnAborted.promise + expect((await running).isError).toBe(true) + expect(ctx.pty.list(owner)).toEqual([]) + }) + + it('rejects invalid config and invalid calls', async () => { + const { ctx, owner, stub } = await setup() + expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) + expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + + const controller = new AbortController() + controller.abort(new Error('caller stopped')) + expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + + expect(() => { + ToolPwshPersistent.apply(new Context(), { backendType: '' }) + }).toThrow('backendType must be non-empty') + expect(() => { + ToolPwshPersistent.apply(new Context(), { timeoutMs: 0 }) + }).toThrow('timeoutMs must be a positive safe integer') + expect(() => { + ToolPwshPersistent.apply(new Context(), { maxOutputChars: 0 }) + }).toThrow('maxOutputChars must be a positive safe integer') + expect(() => { + ToolPwshPersistent.apply(new Context(), { description: ' ' }) + }).toThrow('description must be non-empty') + }) +}) diff --git a/packages/pty/tool-pwsh-persistent/tsconfig.json b/packages/pty/tool-pwsh-persistent/tsconfig.json new file mode 100644 index 0000000000..57c13a61c2 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/agent" }, + { "path": "../../core/tools" }, + { "path": "../pty" }, + { "path": "../../support/invariants" }, + { "path": "../../util/timeout" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 254839878b..b99d0ba5bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -246,6 +246,9 @@ importers: '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../packages/bash/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../packages/pty/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph @@ -5626,6 +5629,61 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/pty/tool-pwsh-persistent: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-pty': + specifier: workspace:^ + version: link:../pty + '@deepseek-ai/dsh-pty-local': + specifier: workspace:^ + version: link:../pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local + '@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-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/sandbox/sandbox: devDependencies: '@deepseek-ai/cordis': diff --git a/tsconfig.host.json b/tsconfig.host.json index b5e43cb523..b2525c8ddf 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -182,6 +182,7 @@ { "path": "./packages/pty/pty" }, { "path": "./packages/pty/pty-local" }, { "path": "./packages/pty/tool-bash-persistent" }, + { "path": "./packages/pty/tool-pwsh-persistent" }, { "path": "./packages/pty/tool-pty" }, { "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/code-runtime/code-runtime-worker" }, From da4701d28b984bc16f9aabddaddb80af55c973eb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:28:29 +0800 Subject: [PATCH 011/180] docs(pty): persistent pwsh READMEs, dialect docs, and the implemented note Adds the tool-pwsh-persistent README trio, documents the pty-local shellDialect and the subprocess-local Windows inspector (console-wide signalling, pseudo foreground groups, taskkill teardown) in both languages, updates the tool-pwsh and persistent-pty notes in place, and moves the pwsh-persistent-pty design note to implemented with the shipped Decision and Consequences. --- .../2026-08-11-pwsh-persistent-pty.i18n.yaml | 6 ++ .../2026-08-11-pwsh-persistent-pty.md | 65 +++++++++++++++++++ .../2026-08-11-pwsh-persistent-pty.zh.md | 65 +++++++++++++++++++ ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +- .../2026-07-16-persistent-pty-sessions.md | 2 +- .../2026-07-16-persistent-pty-sessions.zh.md | 2 +- packages/bash/tool-pwsh/README.i18n.yaml | 4 +- packages/bash/tool-pwsh/README.md | 2 +- packages/bash/tool-pwsh/README.zh.md | 2 +- packages/pty/pty-local/README.i18n.yaml | 4 +- packages/pty/pty-local/README.md | 5 +- packages/pty/pty-local/README.zh.md | 5 +- .../pty/tool-pwsh-persistent/README.i18n.yaml | 6 ++ packages/pty/tool-pwsh-persistent/README.md | 55 ++++++++++++++++ .../pty/tool-pwsh-persistent/README.zh.md | 55 ++++++++++++++++ .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 4 +- .../subprocess/subprocess-local/README.zh.md | 4 +- 18 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md create mode 100644 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md create mode 100644 packages/pty/tool-pwsh-persistent/README.i18n.yaml create mode 100644 packages/pty/tool-pwsh-persistent/README.md create mode 100644 packages/pty/tool-pwsh-persistent/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml new file mode 100644 index 0000000000..c74339813b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.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-pwsh-persistent-pty.md +2026-08-11-pwsh-persistent-pty.md: 51988586a6c260d528e140ab718eb302355c7314 +2026-08-11-pwsh-persistent-pty.zh.md: e753c050827b216330ac9bce0258f9b59d866999 diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md new file mode 100644 index 0000000000..51988586a6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -0,0 +1,65 @@ +# Agent Note: Persistent pwsh over the PTY seam on Windows + +Status: implemented + +English | [中文](2026-08-11-pwsh-persistent-pty.zh.md) + +## Problem + +The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-pty-local` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work. + +The gap excluded Windows workflows whose state lives in a terminal: stepping a debugger, exploring in a Python or Node REPL, or returning to a shell after interrupting its foreground command — the same class of work the persistent bash pty serves on POSIX. + +Two foundations already existed. The PTY service itself (`ctx.pty` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows. + +## Decision + +A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `pty-local`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows. + +### Windows substrate in `@deepseek-ai/dsh-subprocess-local` + +`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes creation-time identities (pid-reuse fencing like the POSIX start identity), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only. + +`LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend. + +### Shell dialect in `@deepseek-ai/dsh-pty-local` + +One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred). + +### `@deepseek-ai/dsh-tool-pwsh-persistent` + +A new package mirroring `tool-bash-persistent`: same `Config` (`backendType` default `shell`, `timeoutMs`, `maxOutputChars`, `description`), same owner-scoped shell registry and serialized per-owner queue, same timeout/abort/exit/reset paths. The tool name is `pwsh`; it never co-mounts with the one-shot `tool-pwsh` because the preset rows are mutually exclusive per platform. + +Commands run through a wrapper that resets `$LASTEXITCODE` (assignable, verified), invokes the body via `Invoke-Expression` in a backtick-escaped double-quoted string (`quoteForPwsh`: backtick, quote, `$`, CRLF, and ESC escapes, so no raw control characters ride the input line and the wrapper survives ConstrainedLanguage), and reports the exact native exit code, `1` for a terminating PowerShell error, or `0` for success. PSReadLine echoes the submitted wrapper back into the stream — there is no `stty -echo` equivalent — so the extraction strips the wrapper source from captured output; the echo can never fabricate completion because the status regex needs digits immediately after the END nonce and the echo continues with quote characters. The prompt function installs the tool's own prompt (`__DSH_PERSISTENT_PWSH_PROMPT__ `) over the backend bootstrap value, the same two-layer structure as bash. + +### Composition + +The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`pty-local` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`pty-local` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh. + +### Testing + +The subprocess-local and pty-local suites now run on Windows: bash-shaped fixtures self-skip through platform gates, the spawn/terminal suites translate their simple shell commands to node one-liners and exercise injected POSIX group paths, and the koffi-backed inspector joins the windows-only coverage exclusions on Linux while the windows-native lane enforces its 100% coverage. The tool suite mirrors `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. + +## Alternatives considered + +- **A separate `pty-pwsh-local` backend package.** Rejected: the local session, sanitizer, readiness tiers, and sandbox fence are shared machinery; duplicating the 500-line session for argv/env/startup differences trades one config field for a package of copy-paste, unlike the bash group's thin parallel executors. +- **tasklist or wmic polling for the process tree.** Rejected: `inspectForeground` runs on every readiness poll (~50 ms), so a spawned probe per tick is untenable, and wmic is removed from current Windows releases. koffi + Toolhelp32 is in-process and cheap. +- **A native helper or `GenerateConsoleCtrlEvent` for SIGINT.** Rejected: writing `\x03` to ConPTY input interrupts running commands (verified) with zero new code. The semantic difference — at a prompt, `\x03` cancels the pending line instead of signalling a process — is documented rather than engineered around. +- **Base64 body encoding for the wrapper.** Rejected: decoding needs `[Convert]`/`[System.Text.Encoding]` calls whose ConstrainedLanguage status is unproven, while backtick-escaped double-quoted strings use only language-level constructs and were verified end-to-end. +- **Tolerating the echo without stripping the wrapper.** Rejected: in complete and prompt-settled paths the echo is naturally excluded, but timeout and lost-START fallbacks would leak the wrapper source (including marker nonces) into model-visible text. +- **Resurrecting a BEL model-notification channel.** Rejected: the current implementation consumes no marker payload and delivers no BEL events; the design aligns with the current implementation and keeps the deferred item deferred. +- **Windows PowerShell 5.1 as a first-class target.** Rejected: pwsh 7 (including the Store install) is the target; `resolvePwshPath` keeps 5.1 as the last-resort executable fallback without promising full persistent-shell behavior on it. + +## Consequences + +**Windows became a first-class persistent-shell host.** The pty family now runs, tests, and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. + +**The windows-native coverage flip is a standing commitment.** subprocess-local and pty-local sources are coverage-required on win32; their suites run there (with platform gates and node-translated commands) and must keep 100% coverage on the windows-native lane. + +**Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier. + +**Windows teardown and signalling differ from POSIX.** taskkill without `/F` does not terminate console processes (the TERM tier is a grace wait before `/F`), SIGINT is console-wide Ctrl-C, SIGTSTP/SIGHUP are unavailable, and externally taskkilled shells may not fire node-pty's exit notification — the handle settles from verified absence instead. + +**Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks. + +**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the prompt function's `[Console]::` call; the `Write-Host -NoNewline` fallback is designed and decided by the Windows-native lane. A model redefinition of the `prompt` function degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md new file mode 100644 index 0000000000..e753c05082 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -0,0 +1,65 @@ +# Agent Note: Windows 上基于 PTY seam 的持久化 pwsh + +Status: implemented + +[English](2026-08-11-pwsh-persistent-pty.md) | 中文 + +## 问题 + +harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-pty-local` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。 + +这个缺口排除了状态驻留在终端里的 Windows 工作流:单步调试、在 Python 或 Node REPL 中探索、中断前台命令后回到原 shell —— 正是持久 bash pty 在 POSIX 上服务的同一类工作。 + +两个基础已经存在。PTY 服务本身(`ctx.pty` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。 + +## 决定 + +模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`pty-local` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。 + +### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座 + +`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 创建时间身份(与 POSIX start-identity 相同的 PID 复用防护),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。 + +`LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。 + +### `@deepseek-ai/dsh-pty-local` 的 shell 方言 + +一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。 + +### `@deepseek-ai/dsh-tool-pwsh-persistent` + +新包镜像 `tool-bash-persistent`:同样的 `Config`(`backendType` 默认 `shell`、`timeoutMs`、`maxOutputChars`、`description`)、同样的 owner 作用域 shell 注册表与每 owner 串行队列、同样的超时/中止/退出/重置路径。工具名是 `pwsh`;它与一次性 `tool-pwsh` 永不共挂,因为预设行按平台互斥。 + +命令经包装器执行:先重置 `$LASTEXITCODE`(可赋值,已实测),通过 `Invoke-Expression` 在反引号转义的双引号字符串中执行 body(`quoteForPwsh`:反引号、引号、`$`、CRLF 与 ESC 转义,输入行上不携带裸控制字符,包装器可在 ConstrainedLanguage 下存活),报告精确原生退出码、PowerShell 终止性错误的 `1` 或成功的 `0`。PSReadLine 会把提交的包装器回显进流——没有 `stty -echo` 的对应物——因此提取会从捕获输出中剥离包装器原文;回显无法伪造完成,因为状态正则要求 END nonce 后紧跟数字,而回显继续是引号字符。prompt 函数安装工具自有提示符(`__DSH_PERSISTENT_PWSH_PROMPT__ `)覆盖 backend 引导值,与 bash 的双层结构相同。 + +### 组合 + +minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`pty-local` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `pty-local` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。 + +### 测试 + +subprocess-local 与 pty-local 套件现在在 Windows 上运行:bash 形态 fixture 通过平台门控自跳过,spawn/terminal 套件把简单 shell 命令翻译为 node 单行并覆盖注入的 POSIX 组路径,koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免,windows-native 车道强制执行其 100% 覆盖。工具套件镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 + +## 备选方案 + +- **独立的 `pty-pwsh-local` backend 包。** 拒绝:本地 session、sanitizer、就绪档位和沙箱栅栏是共享机制;为一个 config 字段复制 500 行 session 换来的是一包复制粘贴,与 bash 组并置薄 executor 的情形不同。 +- **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。 +- **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。 +- **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。 +- **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。 +- **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。 +- **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。 + +## 后果 + +**Windows 成为一等公民的持久 shell 宿主。** pty 家族现在在 windows-native 车道上运行、测试并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 + +**windows-native 覆盖翻转是常驻承诺。** subprocess-local 与 pty-local 源码在 win32 上受覆盖约束;它们的套件在那里运行(带平台门控与 node 翻译命令),并必须在 windows-native 车道保持 100% 覆盖。 + +**Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。 + +**Windows 的拆卸与信号不同于 POSIX。** 不带 `/F` 的 taskkill 无法终止控制台进程(TERM 档是 `/F` 升级前的宽限等待)、SIGINT 是控制台级 Ctrl-C、SIGTSTP/SIGHUP 不可用,且被外部 taskkill 的 shell 可能不触发 node-pty 的退出通知——句柄改从验证的消失状态结算。 + +**输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。 + +**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝 prompt 函数的 `[Console]::` 调用;`Write-Host -NoNewline` 回退已设计好,由 Windows-native 车道裁决。模型重定义 `prompt` 函数会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 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 32fa522443..eab1b8987e 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: fb9cd06bade7bc357baa738f0d9dd03b7f5b7936 -2026-07-16-persistent-pty-sessions.zh.md: 55a5848c1ab1e8c2cd3b29f2d4748ea4abbe088c +2026-07-16-persistent-pty-sessions.md: fa9d90e3ded97b279c5cad7c8b5733333403beaf +2026-07-16-persistent-pty-sessions.zh.md: f801f4d0728452b2a5acf75c5bcbef0d9e1e6901 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 fb9cd06bad..fa9d90e3de 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 @@ -134,7 +134,7 @@ The package ships concise tool guidance explaining persistent state, owner isola - Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited. - Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol. - Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work. -- Windows/ConPTY support requires a backend with Windows-native process ownership and signaling semantics. +- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../../architecture/2026-08-11-pwsh-persistent-pty.md). ## Alternatives considered 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 55a5848c1a..f801f4d072 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 @@ -134,7 +134,7 @@ plugins: - 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。 - harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。 - 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。 -- Windows/ConPTY 支持需要具备 Windows 原生进程所有权与信号语义的后端。 +- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../../architecture/2026-08-11-pwsh-persistent-pty.md)。 ## 备选方案 diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index 04e314ac14..edac189179 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md -README.md: 4126e718c569f17fb8be465351b2576970e93c63 -README.zh.md: aba669733a7ecb9924287abb298bb9a154b5afa7 +README.md: b843ed11823aadec7c28cc26e45da41701e40d3d +README.zh.md: 04f471fd15d6bf4343f674a29d03bb48c4870fdb diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 4126e718c5..b843ed1182 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -121,6 +121,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. -- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. +- **No persistent shell** — every call starts a fresh `pwsh -Command`; the persistent-shell counterpart is [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md), which keeps one owner-scoped pwsh alive across calls on Windows (ConPTY) and POSIX hosts with pwsh. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index aba669733a..04f471fd15 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -121,6 +121,6 @@ ack 是固定短行;任务输出按读取有界。 ## Known Limitations and Deferred Work - **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 -- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 +- **无持久 shell** — 每次调用都启动全新的 `pwsh -Command`;持久 shell 对应物是 [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md),它在 Windows(ConPTY)以及装有 pwsh 的 POSIX 主机上跨调用保持一个 owner 作用域的 pwsh 存活。 - **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/pty/pty-local/README.i18n.yaml b/packages/pty/pty-local/README.i18n.yaml index 238e2aa749..f90a07050d 100644 --- a/packages/pty/pty-local/README.i18n.yaml +++ b/packages/pty/pty-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/pty/pty-local/README.md -README.md: 5acc92853e6e8fcb8938c48e391559bf4a28fb75 -README.zh.md: 7d070a9a4aceab921264f020716fbdf16fdd57ff +README.md: 4565f9a6efd122e6302368c2d616f09649f0cae0 +README.zh.md: 44c1440c20a428048e1509b22141066d16758d31 diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index 5acc92853e..4565f9a6ef 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -8,6 +8,8 @@ Persistent shell backend for `ctx.pty` over `ctx.subprocess.spawnTerminal`. It s The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. +`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. + Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `PtyBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`. @@ -31,6 +33,7 @@ A standing-policy change appends an owner-rendered superseding runtime-context s ## Known Limitations and Deferred Work - Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported. -- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. +- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound. +- The pwsh `prompt` bootstrap writes through `[Console]::`, which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer. - Sessions do not survive harness process exit. diff --git a/packages/pty/pty-local/README.zh.md b/packages/pty/pty-local/README.zh.md index 7d070a9a4a..44c1440c20 100644 --- a/packages/pty/pty-local/README.zh.md +++ b/packages/pty/pty-local/README.zh.md @@ -8,6 +8,8 @@ 该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 +`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 + 就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。 @@ -31,6 +33,7 @@ ## 已知限制与暂缓事项 - 输出按行规范化;不支持全屏备用缓冲区交互。 -- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。 +- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 +- pwsh `prompt` 引导通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 diff --git a/packages/pty/tool-pwsh-persistent/README.i18n.yaml b/packages/pty/tool-pwsh-persistent/README.i18n.yaml new file mode 100644 index 0000000000..3335ee04fc --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/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/pty/tool-pwsh-persistent/README.md +README.md: 32fcaf15d38648c347e6423c9b68753ccca91287 +README.zh.md: 8c2201657796d2d6bd39ea5d130ee246a58e17d9 diff --git a/packages/pty/tool-pwsh-persistent/README.md b/packages/pty/tool-pwsh-persistent/README.md new file mode 100644 index 0000000000..32fcaf15d3 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/README.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-tool-pwsh-persistent + +English | [中文](README.zh.md) + +Model-facing `pwsh(command)` backed by one owner-scoped `ctx.pty` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend (a `pty-local` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect. + +## Config + +| Key | Default | Meaning | +|---|---:|---| +| `backendType` | `shell` | Registered PTY backend used for each Agent shell. | +| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | +| `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. | +| `description` | Persistent-shell description | Model-facing environment contract. | + +## Model Experience + +### Tool schema + +#### What the model sees + +The generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance. + +#### Token effect + +Fixed schema cost while `pwsh` is visible. + +#### KV Cache effect + +Prefix-stable while the configured description and schema remain unchanged. + +### Tool results + +#### What the model sees + +Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the PTY has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. + +#### Token effect + +Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result. + +#### KV Cache effect + +Append-only tool results follow the reusable request prefix. + +## Known Limitations and Deferred Work + +- The tool requires an owning Agent and a real PTY backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh). +- **Input echo is unavoidable**: PowerShell's PSReadLine renders submitted input back into the terminal stream, and there is no `stty -echo` equivalent. The marker-anchored extraction excludes the echo in complete results; the wrapper-source strip covers fallback paths, but a wrapper that wraps across the terminal width may leave a partial echo in partial-output results, bounded by `maxOutputChars`. +- Raw ESC characters inside model commands are unsupported: PSReadLine consumes them before execution. The wrapper escapes the control bytes it needs (`[char]27`-built OSC markers, backtick escapes for the body). +- A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path. +- There is no interactive stdin during a command: a foreground command that reads input blocks until the readiness timeout, which resets the shell. +- SIGTSTP/SIGHUP are unavailable on Windows (backend-rejected); SIGINT is delivered as a console-wide Ctrl-C input write, which at a prompt cancels the pending line instead of signalling a process. +- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the prompt function's `[Console]::` call; the backend's documented `Write-Host -NoNewline` fallback is selected by the Windows-native lane evidence. +- The BEL-terminated OSC marker remains a readiness signal only; a BEL event channel to the model stays deferred, aligned with the current implementation. diff --git a/packages/pty/tool-pwsh-persistent/README.zh.md b/packages/pty/tool-pwsh-persistent/README.zh.md new file mode 100644 index 0000000000..8c22016577 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/README.zh.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-tool-pwsh-persistent + +[English](README.md) | 中文 + +模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.pty` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 PTY backend(配置 `shellDialect: pwsh` 的 `pty-local` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY backend。 | +| `timeoutMs` | `300000` | 单条命令的墙钟上限;超时关闭 shell。 | +| `maxOutputChars` | `16000` | 保留的命令输出字符上限;固定诊断文本在其后追加。 | +| `description` | 持久 shell 描述 | 模型可见的环境契约。 | + +## 模型体验 + +### 工具 schema + +#### 模型看到什么 + +生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh-persistent),含配置的 `description`。本插件不贡献独立的 system-prompt 段落;persona 与环境指引由部署方负责。 + +#### Token 影响 + +`pwsh` 可见期间每个请求有固定的 schema 成本。 + +#### KV Cache 影响 + +配置的 description 与 schema 不变时前缀稳定。 + +### 工具结果 + +#### 模型看到什么 + +命令共享每个 Agent 的一个 shell,因此 cwd、`$env:` 变量、函数和后台任务跨调用保留。结果排除私有完成标记、shell 提示符与回显的输入行(PSReadLine 会把提交的输入渲染回输出流;marker 锚定提取与包装器原文剥离将其移除)。非零包装命令追加 `[exit code: N]` —— 命令运行原生程序时是精确的原生退出码,PowerShell 终止性错误为 `1`。shell 在报告状态前退出的,改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]` 或 `[shell exited]`(backend 两者都没有时;Windows 强杀按无 signal 的 exit 1 报告),然后重置并告知模型下一次调用从全新 shell 开始。长输出保留最早的前缀并附裁剪提示;若 PTY 已丢弃该前缀,结果会明确说明。超时返回有界的部分输出、关闭不确定的 shell 并报告重置。 + +#### Token 影响 + +数据相关。`maxOutputChars` 限制保留的命令输出;固定裁剪、前缀丢失、状态、超时与重置诊断可能扩展结果。 + +#### KV Cache 影响 + +追加式工具结果跟随可复用的请求前缀。 + +## 已知限制与延后工作 + +- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 PTY backend(Windows ConPTY 或 POSIX 上的 pwsh)。 +- **输入回显不可避免**:PowerShell 的 PSReadLine 会把提交的输入渲染回终端流,且没有 `stty -echo` 的对应物。完整结果中 marker 锚定提取排除回显;包装器原文剥离覆盖回退路径,但跨越终端宽度的包装器折行可能在部分输出结果中残留片段回显,受 `maxOutputChars` 约束。 +- 模型命令中的裸 ESC 字符不受支持:PSReadLine 会在执行前吞掉它们。包装器转义它需要的控制字节(`[char]27` 构造的 OSC 标记、body 的反引号转义)。 +- 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。 +- 命令执行期间没有交互 stdin:读取输入的前台命令会阻塞到就绪超时,随后重置 shell。 +- SIGTSTP/SIGHUP 在 Windows 不可用(backend 拒绝);SIGINT 以控制台级 Ctrl-C 输入写入投递,在提示符处取消当前行而非向进程发信号。 +- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝 prompt 函数的 `[Console]::` 调用;backend 文档化的 `Write-Host -NoNewline` 回退由 Windows-native 车道证据裁决。 +- BEL 终结的 OSC 标记仍只是就绪信号;面向模型的 BEL 事件通道保持延后,与当前实现对齐。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 68b8ff7d50..8b6e59abe5 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-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/subprocess/subprocess-local/README.md -README.md: 2817e02861db6caad89cad258d14a90c34afcbaf -README.zh.md: 251b994a35e8bd7c84827957a548a1f352583ac6 +README.md: 0685cb32e4c80589de7a1f284712655a5de05b37 +README.zh.md: 1442d7c62e67c5d006f4b0a43d9ea090dbf2f284 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 2817e02861..0685cb32e4 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes start identities, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's absence through those identities because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement. ## Model Experience @@ -25,7 +25,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Windows tree support is best-effort** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary. -- **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots. +- **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 251b994a35..1442d7c62e 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -11,7 +11,7 @@ - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该能力入口被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 启动身份,把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组),拆卸则通过这些身份验证 shell 已消失——因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 ## 模型体验 @@ -25,7 +25,7 @@ ## 已知限制与暂缓事项 - **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。 -- **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 则使用 `ps` 快照。 +- **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 From 13152903c784d9ee40b29b7108d2bdac3ea7aa16 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:42:40 +0800 Subject: [PATCH 012/180] fix(subprocess): satisfy the oxlint gates in the Windows inspector Routes koffi allocations through a branded NativePtr helper (koffi's TS types are any), binds the creationTime callback instead of passing the unbound method, and braces the no-op signal assertions. --- .../subprocess-local/src/windows-inspector.ts | 24 ++++++++++++++----- .../tests/windows-inspector.spec.ts | 8 +++---- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index 78bea583d8..da5158b4c0 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -86,7 +86,7 @@ export class WindowsProcessInspector implements ProcessInspector { } processTree(rootPid: number): ProcessIdentity[] { - return windowsProcessTree(this.internals.snapshot(), rootPid, this.internals.creationTime) + return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.creationTime(pid)) } processSession(_sessionId: number): ProcessIdentity[] { @@ -231,6 +231,18 @@ function win32Bindings(): Win32Bindings { return cachedBindings } +/** + * Allocate koffi memory as a branded {@link NativePtr}; koffi's TS types are + * `any`, so the cast goes through `unknown` to keep the unsafe surface here. + * @param type - the koffi type to allocate. + * @param count - element count. + * @returns the branded allocation pointer. + */ +function allocNative(type: Parameters[0], count: number): NativePtr { + const value: unknown = koffi.alloc(type, count) + return value as NativePtr +} + /** Enumerate the current process table through Toolhelp32. */ function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { const { PROCESSENTRY32W } = win32Structs() @@ -240,7 +252,7 @@ function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { if (isInvalidHandle(snapshot)) return [] const entries: ProcessEntry[] = [] try { - const entry = koffi.alloc(PROCESSENTRY32W, 1) + const entry = allocNative(PROCESSENTRY32W, 1) koffi.encode(entry, 'uint32', PROCESSENTRY32W.size) let ok = bindings.process32FirstW(snapshot, entry) while (ok !== 0) { @@ -263,10 +275,10 @@ function windowsCreationTime(bindings: Win32Bindings, pid: number): string | und const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) if (isInvalidHandle(handle)) return undefined try { - const creation = koffi.alloc(FILETIME, 1) - const exit = koffi.alloc(FILETIME, 1) - const kernel = koffi.alloc(FILETIME, 1) - const user = koffi.alloc(FILETIME, 1) + const creation = allocNative(FILETIME, 1) + const exit = allocNative(FILETIME, 1) + const kernel = allocNative(FILETIME, 1) + const user = allocNative(FILETIME, 1) /* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and cannot be staged deterministically; the absent-process path is covered and the caller treats undefined as a detector miss. */ diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts index b18945fc10..667c6cae46 100644 --- a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -134,9 +134,9 @@ win32('WindowsProcessInspector over the real koffi bindings', () => { it('reports unreadable identities for absent processes and no-ops tree signalling', () => { const inspector = createWindowsProcessInspector() expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false) - expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGKILL')).not.toThrow() - expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGTERM')).not.toThrow() - expect(() => inspector.signalGroup(0, 'SIGKILL')).not.toThrow() - expect(() => inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL')).not.toThrow() + expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGKILL') }).not.toThrow() + expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGTERM') }).not.toThrow() + expect(() => { inspector.signalGroup(0, 'SIGKILL') }).not.toThrow() + expect(() => { inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL') }).not.toThrow() }) }) From 67e6d7082ecb1ee070f99c0477d4766a99aad08c Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 01:11:46 +0800 Subject: [PATCH 013/180] fix: Windows-native CI findings on latest master Local run of check:ci:windows-complete (the windows-native gate) on latest master surfaced five Windows-only failures, all unreachable by current CI because the native windows job is disabled and the wine gate only covers build+site. - install-lefthook/translation-pairing-merge specs junctioned the real scripts/ and tsx package into fixtures; Windows recursive deletion (Node rmSync and git worktree remove) follows MOUNT_POINT junctions and deleted the repository's own directories mid-run. Fixtures now unlink their reparse points before any recursive removal (shared helper in scripts/test-fixture-cleanup.ts). - workflow-workerthread spawned its worker with an empty env; on Windows os.tmpdir() then degrades to the literal relative path undefined\temp, so tsx wrote its transform cache into a cwd-relative undefined/ directory inside the repo. The worker env now injects the host temp path on win32 (workerSpawnEnv, platform-parameterized and unit-tested on both arms). - workspace-context spec did not stub USERPROFILE (win32 homedir) or a set DSH_HOME, leaking the developer machine's real ~/.dsh/AGENTS.md into discovery. - ui-trajectory client-bundle spec mounted the built artifact without the remote/settingsScope provides the locale plugin needs, so the plugin never activated and no view registered. - subagent temp-fixture cleanup lacked the maxRetries Windows handle release needs under load (EPERM); added retries to the three affected specs and the fixture-cleanup helper. --- .../ui-trajectory/tests/client-bundle.spec.ts | 5 +- .../tests/workspace-context.spec.ts | 9 +++- .../subagent/tests/continuation.spec.ts | 2 +- .../subagent/tests/list-children.spec.ts | 2 +- .../tests/tool-subagent-control.spec.ts | 2 +- .../workflow-workerthread/src/host.ts | 33 +++++++++++-- .../tests/workflow-workerthread.spec.ts | 42 ++++++++++++++--- scripts/install-lefthook.spec.ts | 7 ++- scripts/test-fixture-cleanup.ts | 46 +++++++++++++++++++ scripts/translation-pairing-merge.spec.ts | 12 ++++- 10 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 scripts/test-fixture-cleanup.ts diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index edb2e3072b..eebef86099 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, describe, expect, it } from 'vitest' import { ConversationEventRegistry, ConversationViewRegistry, SlotsService, @@ -82,9 +83,11 @@ describe('tsdown client artifact', () => { // Paging is session-owned; this registration-only probe never renders the // entry, so the binding stays deliberately empty. The locale plugin backs // the locale-aware view tab label (its settings scope needs a connection - // handle). + // handle and the forwarded-event port). ctx.provide('sessions', { binding: () => undefined }) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = await import('@deepseek-ai/dsh-client-locale/client') ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index e95b30fa5b..11b820636e 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -578,10 +578,12 @@ describe('workspace context instruction discovery', () => { const root = await tempRepo() const emptyHome = await tempRepo() // Isolate the default-home fallback: blank DSH_HOME is treated as unset, and - // HOME points at an empty dir so the default ~/.dsh holds no global scope. - // Symlinks are followed, so a real ~/.dsh/AGENTS.md would otherwise leak in. + // the home dirs point at an empty dir so the default ~/.dsh holds no global + // scope. Windows homedir() reads USERPROFILE (not HOME), so both must be + // stubbed or a real ~/.dsh/AGENTS.md would otherwise leak in. vi.stubEnv('DSH_HOME', '') vi.stubEnv('HOME', emptyHome) + if (process.platform === 'win32') vi.stubEnv('USERPROFILE', emptyHome) try { const cwd = join(root, 'child') await mkdir(cwd, { recursive: true }) @@ -622,6 +624,8 @@ describe('workspace context instruction discovery', () => { try { await write(join(home, '.dsh/AGENTS.md'), 'global default rule') + // A set DSH_HOME would override the homedir default and relabel the home. + vi.stubEnv('DSH_HOME', '') vi.resetModules() vi.doMock('node:os', () => ({ homedir: () => home })) const isolated = await import('@deepseek-ai/dsh-workspace-context') @@ -629,6 +633,7 @@ describe('workspace context instruction discovery', () => { expect(files.map(file => file.displayPath)).toEqual(['~/.dsh/AGENTS.md']) } finally { + vi.unstubAllEnvs() vi.doUnmock('node:os') vi.resetModules() await rm(root, { recursive: true, force: true }) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 33f046779f..ca1652f78a 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -53,7 +53,7 @@ class GatedAdapter extends LlmAdapter { const roots: string[] = [] afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }) /** Boot the full continuable stack: loop, persistence, providers, and subagents. */ diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 7a7afb5e22..4fcd9858f1 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -28,7 +28,7 @@ type Script = ConstructorParameters[0] const roots: string[] = [] afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }) /** Boot the continuable stack with real JSONL session persistence. */ 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 4960992068..6e8aa2dfef 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 @@ -47,7 +47,7 @@ const testToolSignal = new AbortController().signal const roots: string[] = [] afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }) async function setupWith(adapter: MockAdapter | GatedAdapter) { diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 501a3555c3..b036d692e6 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -6,6 +6,7 @@ * @module @deepseek-ai/dsh-workflow-workerthread/host */ +import { tmpdir } from 'node:os' import { Worker } from 'node:worker_threads' import type { WorkerOptions } from 'node:worker_threads' import { fileURLToPath } from 'node:url' @@ -28,18 +29,42 @@ interface ChildRecord { disposal?: Promise } +/** + * The scrubbed worker environment: no ambient credentials, no loader flags. + * Windows derives `os.tmpdir()` from `TMP`/`TEMP` and falls back to the + * literal relative path `undefined\temp` when the environment is empty, so + * tsx's transform cache would land in a cwd-relative `undefined/temp` + * directory; the host's real temp path (not a credential) is injected there. + * The unbuilt shape additionally forwards `TSX_TSCONFIG_PATH` for path + * resolution. + * @param platform - host platform; overridable so tests exercise both peer arms. + * @returns the scrubbed worker environment object. + */ +export function workerSpawnEnv(platform: NodeJS.Platform = process.platform): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + if (platform === 'win32') { + const tmp = tmpdir() + env.TMP = tmp + env.TEMP = tmp + } + if (process.env.TSX_TSCONFIG_PATH !== undefined) { + env.TSX_TSCONFIG_PATH = process.env.TSX_TSCONFIG_PATH + } + return env +} + /** * Resolve a built worker bundle or an unbuilt bootstrap that installs both tsx * transforms inside the worker. Both shapes clear `execArgv` and the ambient - * environment; the unbuilt shape forwards only `TSX_TSCONFIG_PATH` for path - * resolution. + * environment (the worker only sees the platform temp path and, unbuilt, + * `TSX_TSCONFIG_PATH`). * @param init - the run payload, passed as `workerData`. * @returns the entry path or URL and the Worker options to spawn it with. */ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: WorkerOptions } { /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */ if (!import.meta.url.endsWith('.ts')) { - return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: {}, execArgv: [] } } + return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: workerSpawnEnv(), execArgv: [] } } } // Resolve tsx only for unbuilt consumers and install it before importing TS. const workerEntry = new URL('./worker.ts', import.meta.url) @@ -56,7 +81,7 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: W entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), options: { workerData: init, - env: process.env.TSX_TSCONFIG_PATH === undefined ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH }, + env: workerSpawnEnv(), execArgv: [], }, } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 7646a002bc..e56254c92e 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import type { Worker } from 'node:worker_threads' import { Context } from '@deepseek-ai/cordis' @@ -9,6 +10,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRu import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRun, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' import WorkerWorkflowEngine, { type Config } from '../src/index.ts' +import { workerSpawnEnv } from '../src/host.ts' import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' import { SessionId } from '@deepseek-ai/dsh-session' @@ -559,24 +561,49 @@ describe('dsh-workflow-workerthread', () => { expect(result.value).toBe('fine') }) - it('the worker spawns with an EMPTY environment: an escaped script finds no ambient credentials', async () => { + it('the worker spawns with a scrubbed environment: an escaped script finds no ambient credentials', async () => { const { ctx, parent } = await setup() // A canary in the HARNESS process's env: with an inherited environment // the escape below would read it back (exactly how DEEPSEEK_API_KEY - // would leak); env: {} in the spawn options is what keeps it out. + // would leak); the worker env keeps every ambient variable out. Windows + // additionally receives the host temp path (TMP/TEMP) so `os.tmpdir()` + // inside the worker resolves instead of degrading to a cwd-relative + // `undefined\temp` (tsx writes its transform cache there). process.env.WORKFLOW_ENV_CANARY = 'leak me' try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).length } + return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).sort() } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ canary: null, keys: 0 }) + const expectedKeys = process.platform === 'win32' ? ['TEMP', 'TMP'] : [] + expect(result.value).toEqual({ canary: null, keys: expectedKeys }) } finally { delete process.env.WORKFLOW_ENV_CANARY } }) + it('workerSpawnEnv injects the host temp path on win32 and leaves the POSIX peer empty', () => { + const tmp = tmpdir() + expect(workerSpawnEnv('win32')).toEqual({ TMP: tmp, TEMP: tmp }) + expect(workerSpawnEnv('linux')).toEqual({}) + }) + + it('workerSpawnEnv forwards TSX_TSCONFIG_PATH when the snapshot harness pins it', () => { + const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + vi.stubEnv('TSX_TSCONFIG_PATH', tsconfig) + try { + expect(workerSpawnEnv('linux')).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) + expect(workerSpawnEnv('win32')).toEqual({ + TMP: tmpdir(), + TEMP: tmpdir(), + TSX_TSCONFIG_PATH: tsconfig, + }) + } finally { + vi.unstubAllEnvs() + } + }) + it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => { const { ctx, parent } = await setup() // The ACP snapshot harness runs the parent with its cwd OUTSIDE the @@ -589,10 +616,13 @@ describe('dsh-workflow-workerthread', () => { try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { keys: Object.keys(proc.env), tsconfig: proc.env.TSX_TSCONFIG_PATH } + return { keys: Object.keys(proc.env).sort(), tsconfig: proc.env.TSX_TSCONFIG_PATH } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ keys: ['TSX_TSCONFIG_PATH'], tsconfig }) + const expectedKeys = process.platform === 'win32' + ? ['TEMP', 'TMP', 'TSX_TSCONFIG_PATH'] + : ['TSX_TSCONFIG_PATH'] + expect(result.value).toEqual({ keys: expectedKeys, tsconfig }) } finally { delete process.env.TSX_TSCONFIG_PATH delete process.env.WORKFLOW_ENV_CANARY diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 2c429bba25..c5a92a675e 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -16,6 +16,7 @@ import { tmpdir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' +import { removeFixtureSafely, unlinkFixtureLinks } from './test-fixture-cleanup.ts' const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url)) const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B %P' @@ -40,7 +41,7 @@ interface CommandResult { } afterEach(() => { - for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true }) + for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture) }) function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult { @@ -282,6 +283,10 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1) const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8') + // Windows Git follows the fixture's MOUNT_POINT junctions into their real + // targets while removing a worktree; unlink them first so the removal + // cannot delete the repository's scripts/ or tsx package. + unlinkFixtureLinks(fixture.linked) git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked]) expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval) expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') diff --git a/scripts/test-fixture-cleanup.ts b/scripts/test-fixture-cleanup.ts new file mode 100644 index 0000000000..9c58eea890 --- /dev/null +++ b/scripts/test-fixture-cleanup.ts @@ -0,0 +1,46 @@ +/** + * Junction-safe fixture cleanup for Windows. Test fixtures junction the REAL + * `scripts/`, `node_modules`, and tsx package directories so installer probes + * resolve through them; Windows recursive deletion — both Node's `rmSync` and + * Git's `worktree remove` — follows MOUNT_POINT junctions into their targets + * and would delete the repository's own directories. POSIX `unlink`/`rm` + * already remove symlinks without following them, so the walk is a no-op + * there. + */ + +import { lstatSync, readdirSync, rmSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Recursively unlink every symbolic link (junction) under `path`. + * @param path - the fixture tree whose reparse points are unlinked. + */ +export function unlinkFixtureLinks(path: string): void { + const visit = (entry: string): void => { + let stat: ReturnType + try { + stat = lstatSync(entry) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + if (stat.isSymbolicLink()) unlinkSync(entry) + return + } + for (const child of readdirSync(entry)) visit(join(entry, child)) + } + visit(path) +} + +/** + * Remove one fixture tree after its junctions are unlinked (see + * {@link unlinkFixtureLinks}). Retries the removal: Windows releases child + * process and antivirus file handles asynchronously, and an unretried + * `rmSync` fails immediately with EPERM under load. + * @param path - the fixture tree to remove. + */ +export function removeFixtureSafely(path: string): void { + unlinkFixtureLinks(path) + rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) +} diff --git a/scripts/translation-pairing-merge.spec.ts b/scripts/translation-pairing-merge.spec.ts index 0ee78a11b7..32924416a6 100644 --- a/scripts/translation-pairing-merge.spec.ts +++ b/scripts/translation-pairing-merge.spec.ts @@ -1,7 +1,14 @@ /** Integration coverage for automatic and explicit pairing-record conflict resolution. */ import { execFileSync, spawnSync } from 'node:child_process' -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { + chmodSync, + mkdtempSync, + mkdirSync, + readFileSync, + symlinkSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -15,6 +22,7 @@ import { renderTranslationPairingRecord, translationPairPaths, } from './translation-pairing-record.ts' +import { removeFixtureSafely } from './test-fixture-cleanup.ts' const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url)) const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url)) @@ -28,7 +36,7 @@ interface Fixture { } afterEach(() => { - for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true }) + for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture) }) function git(fixture: Fixture, args: string[]): string { From 709912b788944627dde6cbbf2f782fdd1d3c60f7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 11:18:03 +0800 Subject: [PATCH 014/180] fix(boot): unlink stale profile fallback links instead of rmSync --- ...ink-stale-profile-fallback-links.i18n.yaml | 6 +++++ ...-12-unlink-stale-profile-fallback-links.md | 25 +++++++++++++++++++ ...-unlink-stale-profile-fallback-links.zh.md | 25 +++++++++++++++++++ packages/boot/app-boot/src/profile.ts | 6 +++-- 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.i18n.yaml new file mode 100644 index 0000000000..0e53f1c748 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.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-12-unlink-stale-profile-fallback-links.md +2026-08-12-unlink-stale-profile-fallback-links.md: 32959eb1b83bcc290d1daa4e4a020a2721be489b +2026-08-12-unlink-stale-profile-fallback-links.zh.md: 1f4da12748c9b57c12bf41a740001d5df770beb6 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md new file mode 100644 index 0000000000..32959eb1b8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md @@ -0,0 +1,25 @@ +# Agent Note: Unlink stale profile fallback links instead of rmSync + +Status: implemented + +English | [中文](2026-08-12-unlink-stale-profile-fallback-links.zh.md) + +## Problem + +`healProfilesModuleFallback` re-points `$DSH_HOME/profiles/node_modules` entries when an installation moves, and Windows hosts keep those entries as junctions. `ensureSymlink` deleted a stale entry with `rmSync(link)`, but Node treats a junction as a directory for removal: without `recursive`, `rmSync` throws `ERR_FS_EISDIR`, so every launch from a moved installation or a second worktree crashed before booting. The `replaces a wrong symlink` unit test reproduces that crash on Windows at the exact removal call. + +## Decision + +`ensureSymlink` removes a stale link with `unlinkSync(link)`. `unlink` deletes the reparse point or symlink itself on every platform and never descends into the target, which preserves the function's fail-loud guarantee that a real directory is never deleted. The [profile-plugin-bundles decision](../architecture/2026-08-05-profile-plugin-bundles.md) keeps owning the fallback's two-anchor resolution; this note owns only the removal primitive. + +## Alternatives considered + +**`rmSync(link, { recursive: true })`.** On Node 24 this deletes the junction without following its target, but `recursive` would silently delete a real directory that replaced the link between the `lstat` guard and the removal, weakening the fail-loud contract that motivates the guard. + +**`rmdirSync(link)`.** Removes a junction on Windows as well, but it reads as directory removal for a link, and `unlinkSync` is the repository's existing junction-cleanup idiom. + +**Delete and recreate every entry unconditionally.** Correct but churns unchanged links on every launch and widens the concurrent-heal race window. + +## Consequences + +Windows launches heal moved or second-checkout installations instead of crashing with `ERR_FS_EISDIR`; POSIX behavior is unchanged because `unlinkSync` also unlinks plain symlinks. The existing `replaces a wrong symlink` test now passes on Windows where it previously reproduced the crash. Two concurrent healers deleting the same stale link still surface the second deletion as `ENOENT`, unchanged from the previous `rmSync` implementation. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md new file mode 100644 index 0000000000..1f4da12748 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 用 unlink 删除过期的 profile 回退链接而非 rmSync + +Status: implemented + +[English](2026-08-12-unlink-stale-profile-fallback-links.md) | 中文 + +## 问题 + +`healProfilesModuleFallback` 在安装位置迁移时会把 `$DSH_HOME/profiles/node_modules` 中的条目重新指向新目标,而 Windows 主机上这些条目是 junction。`ensureSymlink` 原先用 `rmSync(link)` 删除过期条目,但 Node 在删除时把 junction 当作目录处理:不带 `recursive` 的 `rmSync` 会抛 `ERR_FS_EISDIR`,于是从迁移后的安装或第二个 worktree 启动时,每次都会在应用引导前崩溃。`replaces a wrong symlink` 单元测试在 Windows 上正好在该删除调用处复现了这一崩溃。 + +## 决策 + +`ensureSymlink` 改用 `unlinkSync(link)` 删除过期链接。`unlink` 在所有平台上都只删除重解析点或符号链接本身、绝不进入目标目录,从而保住该函数“真实目录永远不会被删除”的大声失败保证。[profile-plugin-bundles 决策](../architecture/2026-08-05-profile-plugin-bundles.md)继续拥有回退目录的双锚点解析;本 note 只拥有“用哪个删除原语”这一决定。 + +## 考虑过的替代方案 + +**`rmSync(link, { recursive: true })`。** Node 24 上它只删 junction、不跟随目标,但 `recursive` 会在 `lstat` 守卫与删除之间链接被替换成真实目录时静默删除该目录,削弱守卫存在所依据的大声失败契约。 + +**`rmdirSync(link)`。** Windows 上同样能删 junction,但它读起来像“删目录”,而 `unlinkSync` 才是仓库现有的 junction 清理惯例。 + +**无条件删除并重建所有条目。** 正确,但每次启动都翻动未变化的链接,并扩大并发修复的竞态窗口。 + +## 后果 + +Windows 启动现在可以修复迁移后的安装或第二个 checkout,而不是以 `ERR_FS_EISDIR` 崩溃;POSIX 行为不变,因为 `unlinkSync` 同样能 unlink 普通符号链接。现有的 `replaces a wrong symlink` 测试在 Windows 上从复现崩溃变为通过。两个并发 healer 删除同一过期链接时,第二次删除仍会以 `ENOENT` 浮现,与原先的 `rmSync` 实现一致。 diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 11f22c6df7..efa60eb78e 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -24,7 +24,7 @@ import { createRequire } from 'node:module' import { - existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' import { basename, dirname, join } from 'node:path' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' @@ -182,7 +182,9 @@ function ensureSymlink(link: string, target: string): void { throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`) } if (readlinkSync(link) === target) return - rmSync(link) + // unlink deletes the reparse point itself on Windows too; rmSync treats a + // junction as a directory and throws EISDIR unless recursive. + unlinkSync(link) } try { symlinkSync(target, link, 'junction') From e37af006c5c8b02e4a4c4302f994ee0f318be89e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 11:19:01 +0800 Subject: [PATCH 015/180] test(app-boot): unlink hmr alias junctions before removing the target --- packages/boot/app-boot/tests/hmr-config.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index c248643130..5f52ab5e2a 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -62,7 +62,7 @@ describe('HMR exact config paths', () => { expect(cacheHas).toHaveBeenCalledWith(expected) } finally { await ctx.fiber.dispose() - rmSync(alias, { force: true }) + unlinkSync(alias) rmSync(target, { recursive: true, force: true }) } }) @@ -78,7 +78,7 @@ describe('HMR exact config paths', () => { .rejects.toThrow('config path already registered') } finally { await ctx.fiber.dispose() - rmSync(alias, { force: true }) + unlinkSync(alias) rmSync(target, { recursive: true, force: true }) } }) From 874d7c4f78578790b7ba2e27da92cff216d0b282 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 11:32:03 +0800 Subject: [PATCH 016/180] docs: add junction-safe unlink rule to defensive patterns --- docs/defensive-patterns.i18n.yaml | 4 ++-- docs/defensive-patterns.md | 4 ++++ docs/defensive-patterns.zh.md | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 18b28ca58c..caf07bb52f 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: 368c9876f1a4e7042b003f6acfb30af3b2daf402 -defensive-patterns.zh.md: c7d4c1bf37ef17947913ac4011624d04ffd8c1a3 +defensive-patterns.md: b396b6b88f37b3ccea25321fb25534087911f8d4 +defensive-patterns.zh.md: de4c1d7f17cdde48596460bb4f76a4b0f6667c5a diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index 368c9876f1..b396b6b88f 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -27,3 +27,7 @@ A user-supplied listener that throws must not reject the promise it runs inside ## Never hand untrusted output the ambient environment or predictable paths Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. + +## Unlink link-shaped paths + +A path that may be a symlink or Windows junction is removed with `lstatSync().isSymbolicLink()` then `unlinkSync`: unlink deletes only the link and refuses a real directory, so it never follows the link into its target. Windows `rmSync(link)` throws `ERR_FS_EISDIR` on a junction; recursive deletion may descend through one into its target. Reserve recursive `rmSync` for known real directories. diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index c7d4c1bf37..de4c1d7f17 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -27,3 +27,7 @@ ## 绝不将环境变量或可预测路径暴露给不可信输出 启动的命令应使用经过清理的环境变量,移除名称匹配 `*KEY*`、`*SECRET*`、`*TOKEN*` 或 `*PASSWORD*` 的项,防止 harness 凭证通过命令输出、`env` 或 spill 文件泄漏。临时文件和 spill 文件应放在权限为 0700 的私有目录中,使用随机文件名,并以独占且仅所有者可访问的方式打开(`'wx'`、`0o600`);可预测且全局可读的路径会引发符号链接竞态和信息泄露。 + +## 用 unlink 删除链接形态的路径 + +可能是符号链接或 Windows junction 的路径,应先用 `lstatSync().isSymbolicLink()` 判断,再用 `unlinkSync` 删除:unlink 只删除链接本身并拒绝真实目录,因此绝不会跟随链接进入其目标。Windows 上对 junction 调用 `rmSync(link)` 会抛 `ERR_FS_EISDIR`;递归删除可能穿过 junction 进入其目标。真实目录才使用带 `recursive` 的 `rmSync`。 From 772b5a2ec81bfc70b812b56e46ecf0bdaa9ca047 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 14:04:45 +0800 Subject: [PATCH 017/180] fix(pwsh): resolve Store app execution aliases --- ...08-12-resolve-store-pwsh-aliases.i18n.yaml | 6 +++++ .../2026-08-12-resolve-store-pwsh-aliases.md | 23 +++++++++++++++++++ ...026-08-12-resolve-store-pwsh-aliases.zh.md | 23 +++++++++++++++++++ packages/bash/pwsh-local/src/resolve.ts | 17 ++++++++++++-- .../bash/pwsh-local/tests/executor.spec.ts | 20 +++++++++++++--- 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.i18n.yaml new file mode 100644 index 0000000000..140c7b9ae7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.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-12-resolve-store-pwsh-aliases.md +2026-08-12-resolve-store-pwsh-aliases.md: 20fe58e15e75462dc0a9ba76c7a1a94939f8a004 +2026-08-12-resolve-store-pwsh-aliases.zh.md: bbfa4616127a9dbdb6609fe2973283663de55b31 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md new file mode 100644 index 0000000000..20fe58e15e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md @@ -0,0 +1,23 @@ +# Agent Note: Resolve Microsoft Store pwsh aliases + +Status: implemented + +English | [中文](2026-08-12-resolve-store-pwsh-aliases.zh.md) + +## Problem + +`resolvePwshPath` documented that Microsoft Store installs resolve through PATH, but its existence probe was `existsSync`, which stats a candidate and therefore follows reparse points. The Store's `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` is an app execution alias whose target directory ACL refuses stat (EACCES), so `existsSync` missed it and resolution silently fell through to Windows PowerShell 5.1 on hosts whose only PowerShell 7 is a Store install. + +## Decision + +`candidateExists` accepts a candidate that stats as a file or that lstat sees as a link-shaped reparse point, and `resolvePwshPath` uses it. Spawning the alias path works because CreateProcess resolves app execution aliases. A dangling link-shaped candidate is accepted so a broken pwsh fails loudly at spawn instead of silently downgrading to 5.1. + +## Alternatives considered + +**Probe the WindowsApps package directory directly.** The Store package path is versioned and ACL-hidden; hard-coding it duplicates packaging knowledge that PATH plus the alias already owns. + +**Keep the 5.1 fallback for stat failures.** Rejected: it silently runs a different shell than the one installed, which is the defect this note fixes. + +## Consequences + +Store-installed PowerShell 7 now resolves ahead of the 5.1 fallback on Windows; real-file candidates and non-Windows behavior are unchanged. The dangling-symlink unit test pins the stat/lstat split on every platform. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md new file mode 100644 index 0000000000..bbfa461612 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 解析 Microsoft Store 的 pwsh 别名 + +Status: implemented + +[English](2026-08-12-resolve-store-pwsh-aliases.md) | 中文 + +## 问题 + +`resolvePwshPath` 声称 Store 安装经 PATH 解析,但它的存在性探测用的是 `existsSync`,会对候选做 stat、从而跟随重解析点。Store 的 `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` 是 app execution alias,其目标目录的 ACL 拒绝 stat(EACCES),于是 `existsSync` 看不到它,解析静默落到 Windows PowerShell 5.1——在这类「唯一的 PowerShell 7 是 Store 安装」的机器上就用了错误的 shell。 + +## 决策 + +`candidateExists` 接受「stat 为文件」或「lstat 为链接形态重解析点」的候选,`resolvePwshPath` 改用它。spawn 别名路径可以工作,因为 CreateProcess 会解析 app execution alias。悬空的链接形态候选同样被接受,让损坏的 pwsh 在 spawn 时响亮失败,而不是静默降级到 5.1。 + +## 考虑过的替代方案 + +**直接探测 WindowsApps 包目录。** Store 包路径带版本且被 ACL 隐藏;硬编码它只是重复了 PATH 加别名已经拥有的打包知识。 + +**对 stat 失败继续走 5.1 回退。** 否决:它静默运行了一个并非所装的 shell,这正是本 note 修复的缺陷。 + +## 后果 + +Windows 上 Store 安装的 PowerShell 7 现在先于 5.1 回退被解析;普通文件候选和非 Windows 平台行为不变。悬空 symlink 单元测试在全部平台上钉住 stat/lstat 的分裂行为。 diff --git a/packages/bash/pwsh-local/src/resolve.ts b/packages/bash/pwsh-local/src/resolve.ts index c6ded2f883..f090c2b4c1 100644 --- a/packages/bash/pwsh-local/src/resolve.ts +++ b/packages/bash/pwsh-local/src/resolve.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-pwsh-local/resolve */ -import { existsSync } from 'node:fs' +import { existsSync, lstatSync } from 'node:fs' import { join } from 'node:path' /** @@ -36,6 +36,19 @@ export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string return candidates } +/** Whether a candidate can be spawned: a real file or a link-shaped reparse point. */ +function candidateExists(candidate: string): boolean { + if (existsSync(candidate)) return true + // Microsoft Store app execution aliases are reparse points whose target + // ACL refuses stat(), so existsSync misses them; lstat sees the link + // itself and CreateProcess resolves it when the executor spawns. + try { + return lstatSync(candidate).isSymbolicLink() + } catch { + return false + } +} + /** * Resolve the pwsh executable this executor spawns. * @param configured - an explicit `pwshPath` config value, trusted as-is. @@ -53,7 +66,7 @@ export function resolvePwshPath( if (configured !== undefined && configured.length > 0) return configured if (platform === 'win32') { for (const candidate of candidatePwshPaths(env)) { - if (existsSync(candidate)) return candidate + if (candidateExists(candidate)) return candidate } } return 'pwsh' diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index f84cc461d4..f6876d05c8 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -9,7 +9,7 @@ * writes CRLF on Windows, so exact text assertions normalize line endings. */ -import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -126,6 +126,18 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32')) .toBe('pwsh') }) + + it('accepts a link-shaped PATH candidate whose target cannot be stat-ed', () => { + // Store app execution aliases stat as EACCES but lstat as a link; a + // dangling symlink reproduces that split on every platform. + const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-link-')) + const store = join(dir, 'store') + mkdirSync(store, { recursive: true }) + const link = join(store, 'pwsh.exe') + symlinkSync(join(dir, 'no-such-target.exe'), link) + expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32')) + .toBe(link) + }) }) describe('spawn construction (pure, every platform)', () => { @@ -298,8 +310,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' it('start returns immediately with a running handle that settles as completed', async () => { const { bash } = await setup() const before = Date.now() - const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' })) - expect(Date.now() - before).toBeLessThan(150) + // The sleep outlasts any realistic spawn latency, so returning while the + // child still sleeps proves start() does not wait for completion. + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 2000; Write-Output done' })) + expect(Date.now() - before).toBeLessThan(1000) expect(proc.status).toBe('running') await proc.done expect(proc.status).toBe('completed') From 06891c762887c6ff55b81f95ea1525169e13918a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 14:04:57 +0800 Subject: [PATCH 018/180] test(claude-code): isolate ambient Anthropic model env --- .../tests/real-product.spec.ts | 18 +++++++++++++++++- 1 file changed, 17 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 9320cb0e7c..dbf8f0d7d3 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -17,7 +17,7 @@ import type { SDKSystemMessage, } from '@anthropic-ai/claude-agent-sdk' import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' @@ -87,6 +87,22 @@ const roots: string[] = [] const fixtures: MessagesFixture[] = [] const contexts: Context[] = [] +// Ambient Anthropic model env leaks into the real CLI and overrides the +// fixture settings.json on developer machines; delete it for this file and +// restore it after, like the workspace-context USERPROFILE isolation. +const ambientAnthropicModel = process.env.ANTHROPIC_MODEL +const ambientAnthropicSmallFastModel = process.env.ANTHROPIC_SMALL_FAST_MODEL + +beforeAll(() => { + delete process.env.ANTHROPIC_MODEL + delete process.env.ANTHROPIC_SMALL_FAST_MODEL +}) + +afterAll(() => { + if (ambientAnthropicModel !== undefined) process.env.ANTHROPIC_MODEL = ambientAnthropicModel + if (ambientAnthropicSmallFastModel !== undefined) process.env.ANTHROPIC_SMALL_FAST_MODEL = ambientAnthropicSmallFastModel +}) + afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) From d58bd913563a245019626139e17c45b28579f2a1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 15:11:05 +0800 Subject: [PATCH 019/180] fix(workflow): forward the tsconfig pin only in the unbuilt worker --- .../workflow/workflow-workerthread/src/host.ts | 13 ++++++++----- .../tests/workflow-workerthread.spec.ts | 17 ++++++----------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index b036d692e6..d17059ad8f 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -38,18 +38,21 @@ interface ChildRecord { * The unbuilt shape additionally forwards `TSX_TSCONFIG_PATH` for path * resolution. * @param platform - host platform; overridable so tests exercise both peer arms. + * @param tsconfigPath - the tsconfig pin to forward; only the unbuilt caller + * passes one, so the built worker never observes the host's pin. * @returns the scrubbed worker environment object. */ -export function workerSpawnEnv(platform: NodeJS.Platform = process.platform): NodeJS.ProcessEnv { +export function workerSpawnEnv( + platform: NodeJS.Platform = process.platform, + tsconfigPath: string | undefined = undefined, +): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} if (platform === 'win32') { const tmp = tmpdir() env.TMP = tmp env.TEMP = tmp } - if (process.env.TSX_TSCONFIG_PATH !== undefined) { - env.TSX_TSCONFIG_PATH = process.env.TSX_TSCONFIG_PATH - } + if (tsconfigPath !== undefined) env.TSX_TSCONFIG_PATH = tsconfigPath return env } @@ -81,7 +84,7 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: W entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), options: { workerData: init, - env: workerSpawnEnv(), + env: workerSpawnEnv(undefined, process.env.TSX_TSCONFIG_PATH), execArgv: [], }, } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index e56254c92e..de26d822f7 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -591,17 +591,12 @@ describe('dsh-workflow-workerthread', () => { it('workerSpawnEnv forwards TSX_TSCONFIG_PATH when the snapshot harness pins it', () => { const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) - vi.stubEnv('TSX_TSCONFIG_PATH', tsconfig) - try { - expect(workerSpawnEnv('linux')).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) - expect(workerSpawnEnv('win32')).toEqual({ - TMP: tmpdir(), - TEMP: tmpdir(), - TSX_TSCONFIG_PATH: tsconfig, - }) - } finally { - vi.unstubAllEnvs() - } + expect(workerSpawnEnv('linux', tsconfig)).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) + expect(workerSpawnEnv('win32', tsconfig)).toEqual({ + TMP: tmpdir(), + TEMP: tmpdir(), + TSX_TSCONFIG_PATH: tsconfig, + }) }) it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => { From db05dad2c7bf929d5c070d5c936a845247b3df4c Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 15:11:20 +0800 Subject: [PATCH 020/180] fix(pwsh): accept link-shaped candidates and sync the README contract --- packages/bash/pwsh-local/README.i18n.yaml | 4 ++-- packages/bash/pwsh-local/README.md | 2 +- packages/bash/pwsh-local/README.zh.md | 2 +- packages/bash/pwsh-local/src/resolve.ts | 20 ++++++++++++------- .../bash/pwsh-local/tests/executor.spec.ts | 11 ++++++++++ 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index 46929dd0f2..a1ecf86130 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-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/bash/pwsh-local/README.md -README.md: 76f3071dc1049e2ea5929d5990ee0cb526ef702e -README.zh.md: e773e0e83e81ffa311bd555b7a75433ba22dfd22 +README.md: 234c1e45cff2b31b93665af582cdf67ce9459f4c +README.zh.md: 8cf750c47b68b835222067652e377d0de977516f diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index 76f3071dc1..234c1e45cf 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -30,7 +30,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic - **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. - **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.bash`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section. - **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. -- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem. +- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking each candidate with an lstat probe that accepts a real file or a link-shaped reparse point (a Store app execution alias stat-fails against its target's ACL, but lstat sees the alias itself); elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem. - **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. - **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index e773e0e83e..8cf750c47b 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -30,7 +30,7 @@ - **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 - **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.bash` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 -- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。 +- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一用 lstat 探测检查(接受真实文件或链接形态的重解析点:Store 的 app execution alias 对其目标 stat 会因 ACL 失败,但 lstat 能看到别名本身);其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。 - **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 diff --git a/packages/bash/pwsh-local/src/resolve.ts b/packages/bash/pwsh-local/src/resolve.ts index f090c2b4c1..191abba963 100644 --- a/packages/bash/pwsh-local/src/resolve.ts +++ b/packages/bash/pwsh-local/src/resolve.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-pwsh-local/resolve */ -import { existsSync, lstatSync } from 'node:fs' +import { lstatSync } from 'node:fs' import { join } from 'node:path' /** @@ -36,15 +36,21 @@ export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string return candidates } -/** Whether a candidate can be spawned: a real file or a link-shaped reparse point. */ +/** + * Whether a candidate can be spawned. lstat opens the entry itself instead of + * following reparse points, so it sees the Store app execution alias where + * stat hits the target's ACL (EACCES); Node reports that alias as a symlink + * on current releases and as a plain file on older ones, and CreateProcess + * resolves either shape. A real directory never matches. + */ function candidateExists(candidate: string): boolean { - if (existsSync(candidate)) return true - // Microsoft Store app execution aliases are reparse points whose target - // ACL refuses stat(), so existsSync misses them; lstat sees the link - // itself and CreateProcess resolves it when the executor spawns. try { - return lstatSync(candidate).isSymbolicLink() + const stat = lstatSync(candidate) + return stat.isFile() || stat.isSymbolicLink() } catch { + // ENOENT (the candidate vanished between listing and probing) is the only + // expected failure; any other error names an unspawnable path, so false + // is the safe answer for it too. return false } } diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index f6876d05c8..1fe555b5e6 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -138,6 +138,17 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32')) .toBe(link) }) + + it('skips a directory candidate and falls through to the PATH-resolution default', () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-dir-')) + const store = join(dir, 'store') + mkdirSync(join(store, 'pwsh.exe'), { recursive: true }) + expect(resolvePwshPath(undefined, { + ProgramFiles: join(dir, 'missing'), + PATH: store, + SystemRoot: join(dir, 'no-windows'), + }, 'win32')).toBe('pwsh') + }) }) describe('spawn construction (pure, every platform)', () => { From 9592842df68df602cc63cdf2c4a092707ca5854a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 15:11:38 +0800 Subject: [PATCH 021/180] docs: record the junction-safe fixture teardown decision --- ...-fixture-junctions-before-delete.i18n.yaml | 6 +++++ ...-unlink-fixture-junctions-before-delete.md | 23 +++++++++++++++++++ ...link-fixture-junctions-before-delete.zh.md | 23 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.i18n.yaml new file mode 100644 index 0000000000..160f947daa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.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-12-unlink-fixture-junctions-before-delete.md +2026-08-12-unlink-fixture-junctions-before-delete.md: 4514a33d728866b817f4e9c1393f16c07976ed45 +2026-08-12-unlink-fixture-junctions-before-delete.zh.md: 3c212c052ab0303ccb8d31d2b310a365a1d8cc99 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md new file mode 100644 index 0000000000..4514a33d72 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md @@ -0,0 +1,23 @@ +# Agent Note: Unlink fixture junctions before recursive deletion + +Status: implemented + +English | [中文](2026-08-12-unlink-fixture-junctions-before-delete.zh.md) + +## Problem + +The install-lefthook and translation-pairing fixtures junction the repository's real `scripts/`, `node_modules`, and tsx package directories into fixture trees so installer probes resolve through them. Windows recursive deletion can treat a junction (a MOUNT_POINT reparse point) as a directory and follow it into its target; Git's `worktree remove` did exactly that and deleted the repository's tracked `scripts/` and tsx package (the incident's instrumentation pinned the deletion to that step). A fixture cleanup that trusts its deleter therefore deletes the repository's own sources instead of the fixture. + +## Decision + +`scripts/test-fixture-cleanup.ts` owns junction-safe fixture teardown: `unlinkFixtureLinks` walks a tree and unlinks every reparse point before `removeFixtureSafely` removes the now link-free tree (with Windows async-handle retries). Every affected `afterEach` and the pre-`worktree remove` hook call it. The general rule lives in `docs/defensive-patterns.md`: remove link-shaped paths with unlink, reserve recursive `rmSync` for known real directories. + +## Alternatives considered + +**Trust recursive deletion alone.** Rejected: whether a given deleter follows junctions is tool- and version-dependent, and one path through `git worktree remove` already destroyed tracked files; no cleanup may bet the repository on that behavior. + +**Copy instead of junctioning the real directories.** Rejected: the fixtures exist to probe the real installer paths through their real contents, so copies would stop exercising the boundary under test. + +## Consequences + +Fixture teardown can no longer reach repository sources through junctions. The extra walk is one lstat/unlink pass over small fixture trees. The data-destroying defect now has its durable why beside the defensive-patterns rule, and the helper is the shared teardown path for future junction fixtures. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md new file mode 100644 index 0000000000..3c212c052a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 递归删除前先解链 fixture junction + +Status: implemented + +[English](2026-08-12-unlink-fixture-junctions-before-delete.md) | 中文 + +## 问题 + +install-lefthook 与 translation-pairing 的 fixture 把仓库真实的 `scripts/`、`node_modules` 和 tsx 包目录用 junction 链进 fixture 树,让 installer 探测能穿透解析。Windows 的递归删除可能把 junction(MOUNT_POINT 重解析点)当作目录并跟随进其目标;Git 的 `worktree remove` 正是这样删掉了仓库被跟踪的 `scripts/` 和 tsx 包(事故的插桩把删除定位到这一步)。因此,信任删除器的 fixture 清理删掉的是仓库自己的源码,而不是 fixture。 + +## 决策 + +`scripts/test-fixture-cleanup.ts` 拥有 junction 安全的 fixture 拆除:`unlinkFixtureLinks` 先遍历并解链所有重解析点,`removeFixtureSafely` 再删除已无链接的树(带 Windows 异步句柄重试)。所有受影响的 `afterEach` 和 `worktree remove` 前的钩子都调用它。通用规则记录在 `docs/defensive-patterns.md`:链接形态的路径用 unlink 删除,递归 `rmSync` 只留给确知为真实目录的路径。 + +## 考虑过的替代方案 + +**只信任递归删除。** 否决:特定删除器是否跟随 junction 随工具和版本而异,而 `git worktree remove` 这一条路径已经摧毁过被跟踪文件;任何清理都不该拿仓库去赌这个行为。 + +**复制而不是 junction 真实目录。** 否决:fixture 的意义就是用真实内容探测真实 installer 路径,复制品会失去被测边界。 + +## 后果 + +fixture 拆除不再能穿过 junction 触及仓库源码。额外开销只是对小型 fixture 树的一趟 lstat/unlink。这个摧毁数据的缺陷现在在 defensive-patterns 规则旁有了持久化的原因,helper 也是未来所有 junction fixture 共享的拆除路径。 From d992eb116e9370848947e23b45768ba0b1e346c7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 16:10:45 +0800 Subject: [PATCH 022/180] fix(workflow): use optional syntax for the tsconfig pin parameter --- packages/workflow/workflow-workerthread/src/host.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index d17059ad8f..2273e6ca1b 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -44,7 +44,7 @@ interface ChildRecord { */ export function workerSpawnEnv( platform: NodeJS.Platform = process.platform, - tsconfigPath: string | undefined = undefined, + tsconfigPath?: string, ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} if (platform === 'win32') { From d05351a270f830fa9d7f9f9ffb8f54f1b37b0386 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 01:11:20 +0800 Subject: [PATCH 023/180] docs(pty): register the persistent pwsh tool in the catalogs Adds tool-pwsh-persistent to the tool-catalog manifest, regenerates docs/tool-catalog.md and docs/config-catalog.md (the pty-local shellDialect config), and fixes the persistent-pty note's cross-link level to the implemented pwsh note. --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +-- .../2026-07-16-persistent-pty-sessions.md | 2 +- .../2026-07-16-persistent-pty-sessions.zh.md | 2 +- docs/config-catalog.md | 31 +++++++++++++++++-- docs/tool-catalog.md | 26 ++++++++++++++++ scripts/gen-tool-catalog.ts | 14 +++++++++ 6 files changed, 72 insertions(+), 7 deletions(-) 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 eab1b8987e..a15b09cc7c 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: fa9d90e3ded97b279c5cad7c8b5733333403beaf -2026-07-16-persistent-pty-sessions.zh.md: f801f4d0728452b2a5acf75c5bcbef0d9e1e6901 +2026-07-16-persistent-pty-sessions.md: 252af19ac2cc8ca29509e189ab07d9d147feef63 +2026-07-16-persistent-pty-sessions.zh.md: e63cacde98ec827b4ed1fed4eba39d228bdaf22a 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 fa9d90e3de..252af19ac2 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 @@ -134,7 +134,7 @@ The package ships concise tool guidance explaining persistent state, owner isola - Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited. - Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol. - Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work. -- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../../architecture/2026-08-11-pwsh-persistent-pty.md). +- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../architecture/2026-08-11-pwsh-persistent-pty.md). ## Alternatives considered 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 f801f4d072..e63cacde98 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 @@ -134,7 +134,7 @@ plugins: - 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。 - harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。 - 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。 -- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../../architecture/2026-08-11-pwsh-persistent-pty.md)。 +- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../architecture/2026-08-11-pwsh-persistent-pty.md)。 ## 备选方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 01c942493a..44d6a8c8cf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1229,9 +1229,11 @@ Requires: `pty` · `sandboxPolicy` · `subprocess` export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -1259,9 +1261,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts) +Source: [`packages/pty/pty-local/src/config.ts:10`](../packages/pty/pty-local/src/config.ts) ## `@deepseek-ai/dsh-pwsh-local` @@ -2232,6 +2237,26 @@ export interface Config { Source: [`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +Requires: `tools` · `pty` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +Source: [`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index eeccb974b8..9404a74947 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal API. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (read_image registration)`, `ctx.llm + an image-capable route (read_image execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | @@ -351,6 +352,31 @@ Source: [`packages/pty/tool-bash-persistent/src/index.ts`](../packages/pty/tool- One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +Source: [`packages/pty/tool-pwsh-persistent/src/index.ts`](../packages/pty/tool-pwsh-persistent/src/index.ts) + +One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 73a87f8213..0a836b2f1c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -43,6 +43,7 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' @@ -273,6 +274,19 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.', }, + { + pkg: '@deepseek-ai/dsh-tool-pwsh-persistent', + dir: 'tool-pwsh-persistent', + source: 'packages/pty/tool-pwsh-persistent/src/index.ts', + requires: ['ctx.tools', 'ctx.pty', 'an owning Agent at execution time'], + writes: ['tool/call', 'PTY shell state', 'tool/result'], + async mount(ctx) { + await ctx.plugin(PtyService) + await ctx.plugin(ToolPwshPersistent) + }, + note: + 'One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description.', + }, { pkg: '@deepseek-ai/dsh-tool-str-replace-editor', dir: 'tool-str-replace-editor', From 2b839f8d7b7884aa9313319bd8758c9ea5c78a52 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 01:20:31 +0800 Subject: [PATCH 024/180] docs(i18n): sync the catalog Chinese counterparts for the new tool Mirrors the tool-pwsh-persistent catalog section and the pty-local shellDialect config into the reviewed Chinese counterparts and re-records both pairing sidecars. --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.zh.md | 31 ++++++++++++++++++++++++++++--- docs/tool-catalog.i18n.yaml | 4 ++-- docs/tool-catalog.zh.md | 26 ++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2b0f494ca6..60443c8598 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: 01c942493ad81eb978ff269bee544bbb1560c819 -config-catalog.zh.md: 4be30ea40432e71e2fc53fd17fae107fd631fa06 +config-catalog.md: 44d6a8c8cfcf9faaa9b6c88e1df460103ce11939 +config-catalog.zh.md: a5944796095aef91fe87e927174f05f68ae2bbcb diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4be30ea404..a594479609 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1231,9 +1231,11 @@ export interface PlanModeConfig { export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -1261,9 +1263,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -来源:[`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts) +来源:[`packages/pty/pty-local/src/config.ts:10`](../packages/pty/pty-local/src/config.ts) ## `@deepseek-ai/dsh-pwsh-local` @@ -2233,6 +2238,26 @@ export interface Config { 来源:[`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +需要:`tools` · `pty` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +来源:[`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` 需要:`tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 2acc82ba4a..34d2789889 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: eeccb974b86fddf782f0970e3fb3b63805a007b6 -tool-catalog.zh.md: 485f5e819647c860c0dbe021a4914a49badcec2a +tool-catalog.md: 9404a74947e7706980b6fee971316a1ca18da1d4 +tool-catalog.zh.md: c2ece838449c17928d0eadb3caa6819b45a73d8b diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 485f5e8196..c2ece83844 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,6 +24,7 @@ | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.bash`、`ctx.systemPrompt`、`ctx.bashEnv`、`ctx.tasks at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.bash` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.tasks` 运行时,并通过 `task_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-bash-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`、`cordis_mount`、`cordis_unmount` | `ctx.tools` | `tool/call`、`tool/result`、`process-local temporary Plugin lifecycle` | - | 不在任何随产品发布的树中,需要有意选择启用;临时 Plugin 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。由 cordis_mount 创建的插件在卸载或 DSH 重启之前可以注册**额外的**模型可见工具;发生这类工具集变更时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.pty`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.pty`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | | `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (read_image registration)`、`ctx.llm + an image-capable route (read_image execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 | | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | @@ -353,6 +354,31 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +在持久 PowerShell shell 中运行命令。包括当前目录和已导出环境变量在内的状态会在此 agent 的多次调用之间保留。 + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +来源:[`packages/pty/tool-pwsh-persistent/src/index.ts`](../packages/pty/tool-pwsh-persistent/src/index.ts) + +一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` From db208953b1728686b39e57a0980aa8f32de817cb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 23:57:35 +0800 Subject: [PATCH 025/180] fix(subprocess): keep the windows-inspector Linux coverage exemption and align the pwsh note with master's windows test structure --- .../architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml | 4 ++-- .../architecture/2026-08-11-pwsh-persistent-pty.md | 6 +++--- .../architecture/2026-08-11-pwsh-persistent-pty.zh.md | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index c74339813b..59ea231ed3 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages 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-pwsh-persistent-pty.md -2026-08-11-pwsh-persistent-pty.md: 51988586a6c260d528e140ab718eb302355c7314 -2026-08-11-pwsh-persistent-pty.zh.md: e753c050827b216330ac9bce0258f9b59d866999 +2026-08-11-pwsh-persistent-pty.md: 7d4fe5e21fd4f9d96cfdf54dfbc6273f8aab3b45 +2026-08-11-pwsh-persistent-pty.zh.md: b1bb90218e15617d4445936abe1be19a537ef9f7 diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 51988586a6..7d4fe5e21f 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -38,7 +38,7 @@ The minimal preset gates its persistent shell stack by platform with the #2234 ` ### Testing -The subprocess-local and pty-local suites now run on Windows: bash-shaped fixtures self-skip through platform gates, the spawn/terminal suites translate their simple shell commands to node one-liners and exercise injected POSIX group paths, and the koffi-backed inspector joins the windows-only coverage exclusions on Linux while the windows-native lane enforces its 100% coverage. The tool suite mirrors `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. +The Windows test surface follows master's exemption structure: pty-local and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. ## Alternatives considered @@ -52,9 +52,9 @@ The subprocess-local and pty-local suites now run on Windows: bash-shaped fixtur ## Consequences -**Windows became a first-class persistent-shell host.** The pty family now runs, tests, and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. +**Windows became a first-class persistent-shell host.** The persistent pwsh stack runs and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. -**The windows-native coverage flip is a standing commitment.** subprocess-local and pty-local sources are coverage-required on win32; their suites run there (with platform gates and node-translated commands) and must keep 100% coverage on the windows-native lane. +**Windows coverage keeps master's exemption structure.** subprocess-local and pty-local sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`. **Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index e753c05082..b1bb90218e 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -38,7 +38,7 @@ minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell ### 测试 -subprocess-local 与 pty-local 套件现在在 Windows 上运行:bash 形态 fixture 通过平台门控自跳过,spawn/terminal 套件把简单 shell 命令翻译为 node 单行并覆盖注入的 POSIX 组路径,koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免,windows-native 车道强制执行其 100% 覆盖。工具套件镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 +Windows 测试面沿用 master 的豁免结构:pty-local 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 ## 备选方案 @@ -52,9 +52,9 @@ subprocess-local 与 pty-local 套件现在在 Windows 上运行:bash 形态 f ## 后果 -**Windows 成为一等公民的持久 shell 宿主。** pty 家族现在在 windows-native 车道上运行、测试并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 +**Windows 成为一等公民的持久 shell 宿主。** 持久 pwsh 栈在 windows-native 车道上运行并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 -**windows-native 覆盖翻转是常驻承诺。** subprocess-local 与 pty-local 源码在 win32 上受覆盖约束;它们的套件在那里运行(带平台门控与 node 翻译命令),并必须在 windows-native 车道保持 100% 覆盖。 +**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 pty-local 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。 **Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。 From 974c340bb12eae225327bae2a13dd3d20da54ba0 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 00:12:13 +0800 Subject: [PATCH 026/180] fix(pty): close the exit race between send settlement and the next poll in both persistent shell tools --- .../pty/tool-bash-persistent/src/index.ts | 52 ++++++++++++++---- .../tool-bash-persistent/tests/tools.spec.ts | 30 +++++++++++ .../pty/tool-pwsh-persistent/src/index.ts | 54 +++++++++++++++---- .../tool-pwsh-persistent/tests/tools.spec.ts | 30 +++++++++++ 4 files changed, 146 insertions(+), 20 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index fa2a965231..3116314573 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -197,6 +197,36 @@ function renderShellExitStatus( return appendStatusMarker(content, marker) } +/** + * Render the exited-session result, reset the owner's shell, and reset the + * message that tells the model the next call starts fresh. + * @param shells - the owner-scoped registry to reset. + * @param status - the exited session status (exit code and signal). + * @returns the complete model-facing result. + */ +async function respondToSessionExit( + ctx: Context, + shells: PersistentShells, + owner: Agent, + id: PtySessionId, + status: { exitCode: number | null; signal: NodeJS.Signals | null }, + marker: CommandMarkers, + fallback: string, + fallbackTruncated: boolean, + config: ResolvedConfig, +): Promise { + const snapshot = retainedScrollback(ctx, owner, id) + await shells.reset(owner, 'persistent bash shell exited') + return [ + renderShellExitStatus( + renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + status.exitCode, + status.signal, + ), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') +} + function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { const pending = new WeakMap>() const live = new Map() @@ -286,6 +316,15 @@ async function executeCommand( let fallbackTruncated = false while (true) { + // The shell may flip to exited between iterations (a fast `exit` can + // settle the previous send while its exit event is still in flight); + // re-observing status before the next send closes that gap. + const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + if (status?.kind === 'exited') { + return await respondToSessionExit( + ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config, + ) + } let operation let result try { @@ -328,16 +367,9 @@ async function executeCommand( if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { - const snapshot = retainedScrollback(ctx, owner, id, latest) - await shells.reset(owner, 'persistent bash shell exited') - return [ - renderShellExitStatus( - renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), - result.sessionStatus.exitCode, - result.sessionStatus.signal, - ), - SHELL_RESET_MESSAGE, - ].filter(part => part.length > 0).join('\n') + return await respondToSessionExit( + ctx, shells, owner, id, result.sessionStatus, marker, fallback, fallbackTruncated, config, + ) } if (promptCompleted(result)) { const snapshot = retainedScrollback(ctx, owner, id, latest) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index d43fd64399..789dbaf831 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -98,6 +98,7 @@ type StubMode = | 'incremental-fallback' | 'empty-page-after-latest' | 'paged-scrollback' + | 'exit-after-send' class StubPtySession implements PtyBackendSession { readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' @@ -109,6 +110,7 @@ class StubPtySession implements PtyBackendSession { sends = 0 pendingText = '' historyTruncated = false + throwOnSend = false constructor(mode: StubMode) { this.mode = mode @@ -127,6 +129,7 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } if (this.mode === 'send-error') throw new Error('stub send failed') + if (this.throwOnSend) throw new Error('PTY session has exited') if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { @@ -171,6 +174,18 @@ class StubPtySession implements PtyBackendSession { const incremental = `${start ?? ''}\nincrement\n${this.motd}` return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) } + if (this.mode === 'exit-after-send') { + // A fast `exit` settles the send while the exit event is still in + // flight; the shell flips to exited before the tool's next poll, + // exactly like the real backend. The tool must re-observe status + // instead of sending. + const output = `${start ?? ''}\n` + this.scrollback += output + const settled = this.result(output, 'inferred_idle') + this.statusValue = { kind: 'exited', exitCode: 9, signal: null } + this.throwOnSend = true + return this.operation(Promise.resolve(settled)) + } if (this.mode === 'torn-status') { const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` this.scrollback += output @@ -397,6 +412,21 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') }) + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'exit-after-send' + + const result = text(await call(ctx, owner, 'exit')) + expect(result).toContain('[shell exited: code 9]') + expect(result).toContain('next bash call starts from the workspace') + expect(session.closed).toContain('persistent bash shell exited') + + expect(text(await call(ctx, owner, 'echo "$PWD"'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + it('reports a shell exit when the backend has no code or signal', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub' }) await call(ctx, owner, 'warm up') diff --git a/packages/pty/tool-pwsh-persistent/src/index.ts b/packages/pty/tool-pwsh-persistent/src/index.ts index 53c495585d..e456f47719 100644 --- a/packages/pty/tool-pwsh-persistent/src/index.ts +++ b/packages/pty/tool-pwsh-persistent/src/index.ts @@ -219,6 +219,37 @@ function renderShellExitStatus( return appendStatusMarker(content, marker) } +/** + * Render the exited-session result, reset the owner's shell, and reset the + * message that tells the model the next call starts fresh. + * @param shells - the owner-scoped registry to reset. + * @param status - the exited session status (exit code and signal). + * @returns the complete model-facing result. + */ +async function respondToSessionExit( + ctx: Context, + shells: PersistentShells, + owner: Agent, + id: PtySessionId, + status: { exitCode: number | null; signal: NodeJS.Signals | null }, + marker: CommandMarkers, + wrapped: string, + fallback: string, + fallbackTruncated: boolean, + config: ResolvedConfig, +): Promise { + const snapshot = retainedScrollback(ctx, owner, id) + await shells.reset(owner, 'persistent pwsh shell exited') + return [ + renderShellExitStatus( + renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars), + status.exitCode, + status.signal, + ), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') +} + /** * The pwsh prompt function that overrides the backend bootstrap value with * this tool's own prompt. `[char]27`/`[char]7` build the OSC bytes at runtime @@ -317,6 +348,16 @@ async function executeCommand( let fallbackTruncated = false while (true) { + // The shell may flip to exited between iterations (a fast `exit` can + // settle the previous send while its exit event is still in flight, and + // the echoed wrapper can then carry a marker end without status digits); + // re-observing status before the next send closes that gap. + const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + if (status?.kind === 'exited') { + return await respondToSessionExit( + ctx, shells, owner, id, status, marker, wrapped, fallback, fallbackTruncated, config, + ) + } let operation let result try { @@ -359,16 +400,9 @@ async function executeCommand( if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { - const snapshot = retainedScrollback(ctx, owner, id, latest) - await shells.reset(owner, 'persistent pwsh shell exited') - return [ - renderShellExitStatus( - renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars), - result.sessionStatus.exitCode, - result.sessionStatus.signal, - ), - SHELL_RESET_MESSAGE, - ].filter(part => part.length > 0).join('\n') + return await respondToSessionExit( + ctx, shells, owner, id, result.sessionStatus, marker, wrapped, fallback, fallbackTruncated, config, + ) } if (promptCompleted(result)) { const snapshot = retainedScrollback(ctx, owner, id, latest) diff --git a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts index b1a6ee8395..1f4a480c38 100644 --- a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts @@ -99,6 +99,7 @@ type StubMode = | 'empty-page-after-latest' | 'paged-scrollback' | 'with-echo' + | 'exit-after-send' const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ @@ -113,6 +114,7 @@ class StubPtySession implements PtyBackendSession { sends = 0 pendingText = '' historyTruncated = false + throwOnSend = false constructor(mode: StubMode) { this.mode = mode @@ -131,6 +133,7 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } if (this.mode === 'send-error') throw new Error('stub send failed') + if (this.throwOnSend) throw new Error('PTY session has exited') if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { @@ -178,6 +181,18 @@ class StubPtySession implements PtyBackendSession { this.scrollback += output return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } + if (this.mode === 'exit-after-send') { + // A fast `exit` settles the send with an echoed wrapper (marker end, + // no status digits) while the exit event is still in flight; the shell + // flips to exited before the tool's next poll, exactly like the real + // ConPTY backend. The tool must re-observe status instead of sending. + const output = `${sent}\n${start ?? ''}\n` + this.scrollback += output + const settled = this.result(output, 'inferred_idle') + this.statusValue = { kind: 'exited', exitCode: 9, signal: null } + this.throwOnSend = true + return this.operation(Promise.resolve(settled)) + } if (this.mode === 'incremental-fallback') { const incremental = `${start ?? ''}\nincrement\n${this.motd}` return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) @@ -347,6 +362,21 @@ describe('tool-pwsh-persistent', () => { expect(result).not.toContain('Invoke-Expression') }) + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'exit-after-send' + + const result = text(await call(ctx, owner, 'exit')) + expect(result).toContain('[shell exited: code 9]') + expect(result).toContain('next pwsh call starts from the workspace') + expect(session.closed).toContain('persistent pwsh shell exited') + + expect(text(await call(ctx, owner, 'Write-Output "$PWD"'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { const { ctx, owner, stub, fiber } = await setup({ backendType: 'stub', From 7c6735c4cb9da6b61d0f20fddabf547ad5dec13d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 00:15:40 +0800 Subject: [PATCH 027/180] docs(catalog): refresh config-catalog source lines after the persistent-shell fixes --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 4 ++-- docs/config-catalog.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 04afdcd0e8..9f44a229d6 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: 8e05f123f9313be19bec3cafe0c73403f26133c6 -config-catalog.zh.md: 8744a33bb622150274826fa5a0f5789454be3345 +config-catalog.md: 8bf9562cb4e91c15e162d50121b53c58decfe183 +config-catalog.zh.md: befbba2d71c3f6c2af512404b6054382c962eaff diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8e05f123f9..8bf9562cb4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2156,7 +2156,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:405`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:437`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2306,7 +2306,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) +Source: [`packages/pty/tool-pwsh-persistent/src/index.ts:470`](../packages/pty/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8744a33bb6..befbba2d71 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2158,7 +2158,7 @@ export interface Config { } ``` -来源:[`packages/pty/tool-bash-persistent/src/index.ts:405`](../packages/pty/tool-bash-persistent/src/index.ts) +来源:[`packages/pty/tool-bash-persistent/src/index.ts:437`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2307,7 +2307,7 @@ export interface Config { } ``` -来源:[`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) +来源:[`packages/pty/tool-pwsh-persistent/src/index.ts:470`](../packages/pty/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` From b1daf0eeaf596e91eab70603fea12f45105999dd Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 01:15:03 +0800 Subject: [PATCH 028/180] test(tools): expect both pwsh tool packages in the harvested catalog roster --- packages/core/tools/tests/gen-tool-catalog.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 8e1e6ad5ef..541bc39d58 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { From d6010c2d88c26fc60788e0cee0f2c2c9d518f4fb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 09:52:56 +0800 Subject: [PATCH 029/180] refactor(pty): follow the naming-contract renames across the persistent pwsh stack --- .../2026-08-11-pwsh-persistent-pty.md | 16 +- .../2026-08-11-pwsh-persistent-pty.zh.md | 16 +- apps/cli/tests/windows-shell.spec.ts | 10 +- packages/e2b/e2b/tests/composition.e2e.ts | 2 +- .../shell/tool-bash-persistent/src/index.ts | 4 +- .../tool-pwsh-persistent/README.i18n.yaml | 6 +- .../tool-pwsh-persistent/README.md | 8 +- .../tool-pwsh-persistent/README.zh.md | 6 +- .../tool-pwsh-persistent/package.json | 8 +- .../tool-pwsh-persistent/src/index.ts | 42 +- .../tool-pwsh-persistent/src/invariant.ts | 0 .../tests/loader-composition.spec.ts | 12 +- .../tool-pwsh-persistent/tests/tools.spec.ts | 56 +- .../tool-pwsh-persistent/tsconfig.json | 4 +- .../terminal-bash/tests/local.spec.ts | 14 +- packages/terminal/terminal-bash/tsconfig.json | 2 +- .../tests/workflow-worker-thread.spec.ts | 37 +- pnpm-lock.yaml | 2644 +++++++++-------- scripts/gen-tool-catalog.ts | 6 +- 19 files changed, 1491 insertions(+), 1402 deletions(-) rename packages/{pty => shell}/tool-pwsh-persistent/README.i18n.yaml (54%) rename packages/{pty => shell}/tool-pwsh-persistent/README.md (82%) rename packages/{pty => shell}/tool-pwsh-persistent/README.zh.md (88%) rename packages/{pty => shell}/tool-pwsh-persistent/package.json (90%) rename packages/{pty => shell}/tool-pwsh-persistent/src/index.ts (92%) rename packages/{pty => shell}/tool-pwsh-persistent/src/invariant.ts (100%) rename packages/{pty => shell}/tool-pwsh-persistent/tests/loader-composition.spec.ts (95%) rename packages/{pty => shell}/tool-pwsh-persistent/tests/tools.spec.ts (94%) rename packages/{pty => shell}/tool-pwsh-persistent/tsconfig.json (78%) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 7d4fe5e21f..092302ec00 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -1,4 +1,4 @@ -# Agent Note: Persistent pwsh over the PTY seam on Windows +# Agent Note: Persistent pwsh over the terminal seam on Windows Status: implemented @@ -6,15 +6,15 @@ English | [中文](2026-08-11-pwsh-persistent-pty.zh.md) ## Problem -The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-pty-local` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work. +The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-terminal-bash` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work. The gap excluded Windows workflows whose state lives in a terminal: stepping a debugger, exploring in a Python or Node REPL, or returning to a shell after interrupting its foreground command — the same class of work the persistent bash pty serves on POSIX. -Two foundations already existed. The PTY service itself (`ctx.pty` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows. +Two foundations already existed. the terminal service itself (`ctx.terminals` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows. ## Decision -A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `pty-local`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows. +A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `terminal-bash`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows. ### Windows substrate in `@deepseek-ai/dsh-subprocess-local` @@ -22,7 +22,7 @@ A model-facing persistent `pwsh` tool ships on Windows with the same contract as `LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend. -### Shell dialect in `@deepseek-ai/dsh-pty-local` +### Shell dialect in `@deepseek-ai/dsh-terminal-bash` One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred). @@ -34,11 +34,11 @@ Commands run through a wrapper that resets `$LASTEXITCODE` (assignable, verified ### Composition -The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`pty-local` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`pty-local` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh. +The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`terminal-bash` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`terminal-bash` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh. ### Testing -The Windows test surface follows master's exemption structure: pty-local and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. +The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. ## Alternatives considered @@ -54,7 +54,7 @@ The Windows test surface follows master's exemption structure: pty-local and sub **Windows became a first-class persistent-shell host.** The persistent pwsh stack runs and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. -**Windows coverage keeps master's exemption structure.** subprocess-local and pty-local sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`. +**Windows coverage keeps master's exemption structure.** subprocess-local and terminal-bash sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`. **Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index b1bb90218e..857f78c66f 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Windows 上基于 PTY seam 的持久化 pwsh +# Agent Note: Windows 上基于 terminal seam 的持久化 pwsh Status: implemented @@ -6,15 +6,15 @@ Status: implemented ## 问题 -harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-pty-local` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。 +harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-terminal-bash` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。 这个缺口排除了状态驻留在终端里的 Windows 工作流:单步调试、在 Python 或 Node REPL 中探索、中断前台命令后回到原 shell —— 正是持久 bash pty 在 POSIX 上服务的同一类工作。 -两个基础已经存在。PTY 服务本身(`ctx.pty` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。 +两个基础已经存在。PTY 服务本身(`ctx.terminals` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。 ## 决定 -模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`pty-local` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。 +模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`terminal-bash` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。 ### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座 @@ -22,7 +22,7 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS `LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。 -### `@deepseek-ai/dsh-pty-local` 的 shell 方言 +### `@deepseek-ai/dsh-terminal-bash` 的 shell 方言 一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。 @@ -34,11 +34,11 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS ### 组合 -minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`pty-local` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `pty-local` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。 +minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`terminal-bash` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `terminal-bash` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。 ### 测试 -Windows 测试面沿用 master 的豁免结构:pty-local 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 +Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 ## 备选方案 @@ -54,7 +54,7 @@ Windows 测试面沿用 master 的豁免结构:pty-local 与 subprocess-local **Windows 成为一等公民的持久 shell 宿主。** 持久 pwsh 栈在 windows-native 车道上运行并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 -**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 pty-local 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。 +**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 terminal-bash 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。 **Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。 diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index b4202c0dc3..ce37022cdf 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -142,17 +142,17 @@ describe('shipped agent presets gate both shell tools by platform', () => { const byId = new Map(rows .filter((entry): entry is Record => typeof entry === 'object' && entry !== null) .map(entry => [entry.id, entry])) - // The bash stack (pty-local + persistent-bash) mounts on POSIX only; the - // pwsh twin (pty-local with shellDialect pwsh + persistent-pwsh) mounts on + // The bash stack (terminal-bash + persistent-bash) mounts on POSIX only; the + // pwsh twin (terminal-bash with shellDialect pwsh + persistent-pwsh) mounts on // win32 only — exactly one persistent shell per host. - for (const id of ['pty-local', 'persistent-bash']) { + for (const id of ['terminal-bash', 'persistent-bash']) { expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(true) expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(false) } - for (const id of ['pty-pwsh', 'persistent-pwsh']) { + for (const id of ['terminal-pwsh', 'persistent-pwsh']) { expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(false) expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(true) } - expect(byId.get('pty-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' }) + expect(byId.get('terminal-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' }) }) }) diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 952da5320d..b01e2fb7b8 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -97,7 +97,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { whenIdle: () => Promise.resolve(), } const backend = new BashTerminalBackend(ctx, { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], rows: 24, cols: 80, scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384, pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000, diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 81c17d9669..05fccf8596 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -208,7 +208,7 @@ async function respondToSessionExit( ctx: Context, shells: PersistentShells, owner: Agent, - id: PtySessionId, + id: TerminalSessionId, status: { exitCode: number | null; signal: NodeJS.Signals | null }, marker: CommandMarkers, fallback: string, @@ -319,7 +319,7 @@ async function executeCommand( // The shell may flip to exited between iterations (a fast `exit` can // settle the previous send while its exit event is still in flight); // re-observing status before the next send closes that gap. - const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status if (status?.kind === 'exited') { return await respondToSessionExit( ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config, diff --git a/packages/pty/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml similarity index 54% rename from packages/pty/tool-pwsh-persistent/README.i18n.yaml rename to packages/shell/tool-pwsh-persistent/README.i18n.yaml index 3335ee04fc..0786399570 100644 --- a/packages/pty/tool-pwsh-persistent/README.i18n.yaml +++ b/packages/shell/tool-pwsh-persistent/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/pty/tool-pwsh-persistent/README.md -README.md: 32fcaf15d38648c347e6423c9b68753ccca91287 -README.zh.md: 8c2201657796d2d6bd39ea5d130ee246a58e17d9 +# pnpm run verify-translation-pairing --write packages/shell/tool-pwsh-persistent/README.md +README.md: 7bb66477ab7ffe52039b0c699d48c9ca761ac04c +README.zh.md: b20041b1d42908d4d1e893455d825eafa2e2f87d diff --git a/packages/pty/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md similarity index 82% rename from packages/pty/tool-pwsh-persistent/README.md rename to packages/shell/tool-pwsh-persistent/README.md index 32fcaf15d3..7bb66477ab 100644 --- a/packages/pty/tool-pwsh-persistent/README.md +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Model-facing `pwsh(command)` backed by one owner-scoped `ctx.pty` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend (a `pty-local` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect. +Model-facing `pwsh(command)` backed by one owner-scoped `ctx.terminals` shell. The package owns the tool contract and shell reuse; deployments select the terminal backend (a `terminal-bash` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect. ## Config | Key | Default | Meaning | |---|---:|---| -| `backendType` | `shell` | Registered PTY backend used for each Agent shell. | +| `backendType` | `shell` | Registered terminal backend used for each Agent shell. | | `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | | `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. | | `description` | Persistent-shell description | Model-facing environment contract. | @@ -33,7 +33,7 @@ Prefix-stable while the configured description and schema remain unchanged. #### What the model sees -Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the PTY has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. +Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the terminal has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. #### Token effect @@ -45,7 +45,7 @@ Append-only tool results follow the reusable request prefix. ## Known Limitations and Deferred Work -- The tool requires an owning Agent and a real PTY backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh). +- The tool requires an owning Agent and a real terminal backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh). - **Input echo is unavoidable**: PowerShell's PSReadLine renders submitted input back into the terminal stream, and there is no `stty -echo` equivalent. The marker-anchored extraction excludes the echo in complete results; the wrapper-source strip covers fallback paths, but a wrapper that wraps across the terminal width may leave a partial echo in partial-output results, bounded by `maxOutputChars`. - Raw ESC characters inside model commands are unsupported: PSReadLine consumes them before execution. The wrapper escapes the control bytes it needs (`[char]27`-built OSC markers, backtick escapes for the body). - A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path. diff --git a/packages/pty/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md similarity index 88% rename from packages/pty/tool-pwsh-persistent/README.zh.md rename to packages/shell/tool-pwsh-persistent/README.zh.md index 8c22016577..b20041b1d4 100644 --- a/packages/pty/tool-pwsh-persistent/README.zh.md +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.pty` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 PTY backend(配置 `shellDialect: pwsh` 的 `pty-local` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。 +模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.terminals` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 terminal backend(配置 `shellDialect: pwsh` 的 `terminal-bash` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。 ## 配置 | 键 | 默认值 | 含义 | |---|---:|---| -| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY backend。 | +| `backendType` | `shell` | 每个 Agent shell 使用的已注册 terminal backend。 | | `timeoutMs` | `300000` | 单条命令的墙钟上限;超时关闭 shell。 | | `maxOutputChars` | `16000` | 保留的命令输出字符上限;固定诊断文本在其后追加。 | | `description` | 持久 shell 描述 | 模型可见的环境契约。 | @@ -45,7 +45,7 @@ ## 已知限制与延后工作 -- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 PTY backend(Windows ConPTY 或 POSIX 上的 pwsh)。 +- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 terminal backend(Windows ConPTY 或 POSIX 上的 pwsh)。 - **输入回显不可避免**:PowerShell 的 PSReadLine 会把提交的输入渲染回终端流,且没有 `stty -echo` 的对应物。完整结果中 marker 锚定提取排除回显;包装器原文剥离覆盖回退路径,但跨越终端宽度的包装器折行可能在部分输出结果中残留片段回显,受 `maxOutputChars` 约束。 - 模型命令中的裸 ESC 字符不受支持:PSReadLine 会在执行前吞掉它们。包装器转义它需要的控制字节(`[char]27` 构造的 OSC 标记、body 的反引号转义)。 - 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。 diff --git a/packages/pty/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json similarity index 90% rename from packages/pty/tool-pwsh-persistent/package.json rename to packages/shell/tool-pwsh-persistent/package.json index 1cf2e224d0..5875376802 100644 --- a/packages/pty/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/pty/tool-pwsh-persistent" + "directory": "packages/shell/tool-pwsh-persistent" }, "type": "module", "main": "lib/index.js", @@ -33,7 +33,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -47,8 +47,8 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-pty": "workspace:^", - "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/pty/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts similarity index 92% rename from packages/pty/tool-pwsh-persistent/src/index.ts rename to packages/shell/tool-pwsh-persistent/src/index.ts index e456f47719..cce7ddd001 100644 --- a/packages/pty/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto' 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 type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -48,7 +48,7 @@ interface CapturedOutput { } interface PersistentShells { - get(owner: Agent, signal: AbortSignal): Promise + get(owner: Agent, signal: AbortSignal): Promise reset(owner: Agent, reason: string): Promise } @@ -128,7 +128,7 @@ function commandOutput( } } -function promptCompleted(result: PtySendResult): boolean { +function promptCompleted(result: TerminalSendResult): boolean { return result.viewport.endsWith(SHELL_PROMPT) || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`) || result.viewport.endsWith(`${SHELL_PROMPT}\n`) @@ -164,7 +164,7 @@ async function pause(): Promise { await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) } -function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { +function nextScrollbackOffset(page: TerminalReadResult, offset: number): number | undefined { if (page.text.length === 0 || page.lineEnd <= offset) return undefined return page.lineEnd } @@ -172,15 +172,15 @@ function nextScrollbackOffset(page: PtyReadResult, offset: number): number | und function retainedScrollback( ctx: Context, owner: Agent, - id: PtySessionId, - latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), + id: TerminalSessionId, + latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), ): RetainedOutput { const pages: string[] = latest.text.length === 0 ? [] : [latest.text] let offset = latest.lineEnd let truncated = latest.truncated while (true) { if (offset >= latest.totalLines) break - const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) + const page = ctx.terminals.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) truncated ||= page.truncated if (page.text.length > 0) pages.unshift(page.text) const next = nextScrollbackOffset(page, offset) @@ -230,7 +230,7 @@ async function respondToSessionExit( ctx: Context, shells: PersistentShells, owner: Agent, - id: PtySessionId, + id: TerminalSessionId, status: { exitCode: number | null; signal: NodeJS.Signals | null }, marker: CommandMarkers, wrapped: string, @@ -260,15 +260,15 @@ const PWSH_PROMPT_SETUP = "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + SHELL_PROMPT + "' }" function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { - const pending = new WeakMap>() - const live = new Map() - const creating = new Set>() + const pending = new WeakMap>() + const live = new Map() + const creating = new Set>() const ownerCleanupInstalled = new WeakSet() const lifecycle = new AbortController() - const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { - if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return - await ctx.pty.kill(owner, id, reason) + const close = async (owner: Agent, id: TerminalSessionId, reason: string): Promise => { + if (!ctx.terminals.list(owner).some(snapshot => snapshot.sessionId === id)) return + await ctx.terminals.kill(owner, id, reason) } ctx.effect(() => async () => { @@ -286,14 +286,14 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell if (id !== undefined) await close(owner, id, reason) } - const get = (owner: Agent, signal: AbortSignal): Promise => { + const get = (owner: Agent, signal: AbortSignal): Promise => { const existing = pending.get(owner) if (existing !== undefined) return existing const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) const creation = (async () => { try { const cwd = owner.session.header.cwd - const spawned = await ctx.pty.spawn(owner, { + const spawned = await ctx.terminals.spawn(owner, { type: config.backendType, ...cwd === undefined ? {} : { cwd }, }, combinedSignal) @@ -305,7 +305,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell live.delete(owner) }, 'tool-pwsh-persistent owner cache cleanup') } - const setup = ctx.pty.startSend(owner, spawned.sessionId, { + const setup = ctx.terminals.startSend(owner, spawned.sessionId, { text: PWSH_PROMPT_SETUP, submit: true, signal: combinedSignal, @@ -352,7 +352,7 @@ async function executeCommand( // settle the previous send while its exit event is still in flight, and // the echoed wrapper can then carry a marker end without status digits); // re-observing status before the next send closes that gap. - const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status if (status?.kind === 'exited') { return await respondToSessionExit( ctx, shells, owner, id, status, marker, wrapped, fallback, fallbackTruncated, config, @@ -361,7 +361,7 @@ async function executeCommand( let operation let result try { - operation = ctx.pty.startSend(owner, id, { + operation = ctx.terminals.startSend(owner, id, { text: first ? wrapped : '', submit: first, signal: commandDeadline.signal, @@ -375,7 +375,7 @@ async function executeCommand( const incremental = operation.readOutput() fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport fallbackTruncated ||= incremental.truncated || result.truncated - const latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) + const latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) if (timedOut !== undefined) { const snapshot = retainedScrollback(ctx, owner, id, latest) @@ -464,7 +464,7 @@ function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void { } export const name = 'tool-pwsh-persistent' -export const inject = ['tools', 'pty'] +export const inject = ['tools', 'terminals'] /** Configuration for the persistent pwsh tool. */ export interface Config { diff --git a/packages/pty/tool-pwsh-persistent/src/invariant.ts b/packages/shell/tool-pwsh-persistent/src/invariant.ts similarity index 100% rename from packages/pty/tool-pwsh-persistent/src/invariant.ts rename to packages/shell/tool-pwsh-persistent/src/invariant.ts diff --git a/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts similarity index 95% rename from packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts rename to packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index fc10f9cfe3..1a95d7fe23 100644 --- a/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -11,8 +11,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' 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 PtyService from '@deepseek-ai/dsh-pty' -import * as PtyLocal from '@deepseek-ai/dsh-pty-local' +import TerminalSessionService from '@deepseek-ai/dsh-terminal' +import * as TerminalBash from '@deepseek-ai/dsh-terminal-bash' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -78,14 +78,14 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", - "- name: '@deepseek-ai/dsh-pty'", + "- name: '@deepseek-ai/dsh-terminal'", "- name: '@deepseek-ai/dsh-test-sandbox'", "- name: '@deepseek-ai/dsh-sandbox-policy'", ' config:', ' mode: danger-full-access', ` workspaceRoot: ${JSON.stringify(root)}`, "- name: '@deepseek-ai/dsh-subprocess-local'", - "- name: '@deepseek-ai/dsh-pty-local'", + "- name: '@deepseek-ai/dsh-terminal-bash'", ' config:', ' shellDialect: pwsh', ' pollIntervalMs: 10', @@ -109,11 +109,11 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRegistry], - ['@deepseek-ai/dsh-pty', PtyService], + ['@deepseek-ai/dsh-terminal', TerminalSessionService], ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService], - ['@deepseek-ai/dsh-pty-local', PtyLocal], + ['@deepseek-ai/dsh-terminal-bash', TerminalBash], ['@deepseek-ai/dsh-tool-pwsh-persistent', ToolPwshPersistent], ]) context.loader.internal = { diff --git a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts similarity index 94% rename from packages/pty/tool-pwsh-persistent/tests/tools.spec.ts rename to packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index 1f4a480c38..6a856b1ff8 100644 --- a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -4,17 +4,17 @@ import { CallId } from '@deepseek-ai/dsh-llm' 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 PtyService from '@deepseek-ai/dsh-pty' +import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { - PtyBackend, - PtyBackendSession, - PtyReadRequest, - PtySendOperation, - PtySendRequest, - PtySessionStatus, - PtySignal, - PtyWaitReason, -} from '@deepseek-ai/dsh-pty' + TerminalBackend, + TerminalBackendSession, + TerminalReadRequest, + TerminalSendOperation, + TerminalSendRequest, + TerminalSessionStatus, + TerminalSignal, + TerminalWaitReason, +} from '@deepseek-ai/dsh-terminal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' @@ -104,10 +104,10 @@ type StubMode = const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ -class StubPtySession implements PtyBackendSession { +class StubTerminalSession implements TerminalBackendSession { readonly motd = '__DSH_PERSISTENT_PWSH_PROMPT__ ' readonly pid = 123 - statusValue: PtySessionStatus = { kind: 'running' } + statusValue: TerminalSessionStatus = { kind: 'running' } scrollback = this.motd closed: string[] = [] mode: StubMode @@ -120,7 +120,7 @@ class StubPtySession implements PtyBackendSession { this.mode = mode } - startSend(request: PtySendRequest): PtySendOperation { + startSend(request: TerminalSendRequest): TerminalSendOperation { this.sends += 1 if (request.text.startsWith('function prompt')) { if (this.mode === 'init-exit') { @@ -135,7 +135,7 @@ class StubPtySession implements PtyBackendSession { if (this.mode === 'send-error') throw new Error('stub send failed') if (this.throwOnSend) throw new Error('PTY session has exited') if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { - const done = new Promise>((resolve) => { + const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { const start = START_PATTERN.exec(request.text)?.[0] const end = END_PATTERN.exec(request.text)?.[0] @@ -232,7 +232,7 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } - read(request: PtyReadRequest) { + read(request: TerminalReadRequest) { if (this.mode === 'empty-read') { return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } } @@ -265,7 +265,7 @@ class StubPtySession implements PtyBackendSession { } } - signal(_signal: PtySignal) { + signal(_signal: TerminalSignal) { return Promise.resolve({ delivered: true as const, targetPgid: 123 }) } @@ -278,11 +278,11 @@ class StubPtySession implements PtyBackendSession { this.statusValue = { kind: 'exited', exitCode: 0, signal: null } } - private result(viewport: string, waitReason: PtyWaitReason) { + private result(viewport: string, waitReason: TerminalWaitReason) { return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } } - private operation(done: Promise>, delta = ''): PtySendOperation { + private operation(done: Promise>, delta = ''): TerminalSendOperation { return { done, readOutput: () => ({ delta, truncated: false }), @@ -292,12 +292,12 @@ class StubPtySession implements PtyBackendSession { } function stubBackend(initialMode: StubMode = 'normal') { - const sessions: StubPtySession[] = [] - const backend: PtyBackend = { + const sessions: StubTerminalSession[] = [] + const backend: TerminalBackend = { type: 'stub', async spawn() { if (initialMode === 'spawn-error') throw new Error('stub spawn failed') - const session = new StubPtySession(initialMode) + const session = new StubTerminalSession(initialMode) sessions.push(session) return session }, @@ -314,9 +314,9 @@ async function setup( await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(PtyService) + await ctx.plugin(TerminalSessionService) const stub = stubBackend(initialMode) - ctx.pty.registerBackend(stub.backend) + ctx.terminals.registerBackend(stub.backend) const fiber = await ctx.plugin(ToolPwshPersistent, config) return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } } @@ -433,9 +433,9 @@ describe('tool-pwsh-persistent', () => { await call(ctx, owner, 'another shell') expect(stub.sessions).toHaveLength(3) - const externallyClosed = ctx.pty.list(owner)[0]?.sessionId + const externallyClosed = ctx.terminals.list(owner)[0]?.sessionId expect(externallyClosed).toBeDefined() - await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') + await ctx.terminals.kill(owner, externallyClosed!, 'external cleanup') await fiber.dispose() expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) @@ -572,10 +572,10 @@ describe('tool-pwsh-persistent', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(PtyService) + await ctx.plugin(TerminalSessionService) const spawnStarted = Promise.withResolvers() const spawnAborted = Promise.withResolvers() - ctx.pty.registerBackend({ + ctx.terminals.registerBackend({ type: 'slow', spawn: spec => new Promise((_resolve, reject) => { spawnStarted.resolve(undefined) @@ -595,7 +595,7 @@ describe('tool-pwsh-persistent', () => { await fiber.dispose() await spawnAborted.promise expect((await running).isError).toBe(true) - expect(ctx.pty.list(owner)).toEqual([]) + expect(ctx.terminals.list(owner)).toEqual([]) }) it('rejects invalid config and invalid calls', async () => { diff --git a/packages/pty/tool-pwsh-persistent/tsconfig.json b/packages/shell/tool-pwsh-persistent/tsconfig.json similarity index 78% rename from packages/pty/tool-pwsh-persistent/tsconfig.json rename to packages/shell/tool-pwsh-persistent/tsconfig.json index 57c13a61c2..42ce584d6a 100644 --- a/packages/pty/tool-pwsh-persistent/tsconfig.json +++ b/packages/shell/tool-pwsh-persistent/tsconfig.json @@ -10,8 +10,8 @@ { "path": "../../../vendor/schemastery" }, { "path": "../../core/agent" }, { "path": "../../core/tools" }, - { "path": "../pty" }, - { "path": "../../support/invariants" }, + { "path": "../../terminal/terminal" }, + { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../util/timeout" } ] } diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 40383a99d1..232ef7d984 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -259,7 +259,7 @@ const hasPwsh = spawnSync( { encoding: 'utf8' }, ).status === 0 -describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { +describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => { it('bootstraps a persistent pwsh, persists state, and scrubs secrets', async () => { const previous = process.env.DSH_TEST_SECRET process.env.DSH_TEST_SECRET = 'must-not-leak' @@ -269,15 +269,15 @@ describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { handoffGraceMs: 300, timeoutMs: 8_000, }, 'pwsh') - const created = await ctx.pty.spawn(agent, { type: 'shell', name: 'main', cwd: root }) + const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root }) expect(created.motd).toContain('dsh> ') - const first = ctx.pty.startSend(agent, created.sessionId, { + const first = ctx.terminals.startSend(agent, created.sessionId, { text: '$env:KEEP = "ok"; Set-Location /', submit: true, }) expect((await first.done).waitReason).toBe('stdin_read') - const second = ctx.pty.startSend(agent, created.sessionId, { + const second = ctx.terminals.startSend(agent, created.sessionId, { text: 'Write-Output "keep=$env:KEEP secret=$env:DSH_TEST_SECRET"', submit: true, }) @@ -286,9 +286,9 @@ describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { expect(result.viewport).toContain('secret=') expect(result.viewport).not.toContain('must-not-leak') - expect(ctx.pty.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok') - expect(await ctx.pty.kill(agent, created.sessionId)).toBe(true) - expect(ctx.pty.list(agent)).toEqual([]) + expect(ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok') + expect(await ctx.terminals.kill(agent, created.sessionId)).toBe(true) + expect(ctx.terminals.list(agent)).toEqual([]) } finally { if (previous === undefined) delete process.env.DSH_TEST_SECRET else process.env.DSH_TEST_SECRET = previous diff --git a/packages/terminal/terminal-bash/tsconfig.json b/packages/terminal/terminal-bash/tsconfig.json index 8b8a378cdf..b42e1015be 100644 --- a/packages/terminal/terminal-bash/tsconfig.json +++ b/packages/terminal/terminal-bash/tsconfig.json @@ -18,7 +18,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../../bash/pwsh-local" + "path": "../../shell/pwsh-local" }, { "path": "../../core/agent" diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts index ced9a632f4..c22888479a 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import type { Worker } from 'node:worker_threads' import { Context } from '@deepseek-ai/cordis' @@ -9,6 +10,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRu import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRun, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' import WorkerThreadWorkflowEngine, { type Config } from '../src/index.ts' +import { workerSpawnEnv } from '../src/host.ts' import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' import { SessionId } from '@deepseek-ai/dsh-session' @@ -559,24 +561,44 @@ describe('dsh-workflow-worker-thread', () => { expect(result.value).toBe('fine') }) - it('the worker spawns with an EMPTY environment: an escaped script finds no ambient credentials', async () => { + it('the worker spawns with a scrubbed environment: an escaped script finds no ambient credentials', async () => { const { ctx, parent } = await setup() // A canary in the HARNESS process's env: with an inherited environment // the escape below would read it back (exactly how DEEPSEEK_API_KEY - // would leak); env: {} in the spawn options is what keeps it out. + // would leak); the worker env keeps every ambient variable out. Windows + // additionally receives the host temp path (TMP/TEMP) so `os.tmpdir()` + // inside the worker resolves instead of degrading to a cwd-relative + // `undefined\temp` (tsx writes its transform cache there). process.env.WORKFLOW_ENV_CANARY = 'leak me' try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).length } + return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).sort() } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ canary: null, keys: 0 }) + const expectedKeys = process.platform === 'win32' ? ['TEMP', 'TMP'] : [] + expect(result.value).toEqual({ canary: null, keys: expectedKeys }) } finally { delete process.env.WORKFLOW_ENV_CANARY } }) + it('workerSpawnEnv injects the host temp path on win32 and leaves the POSIX peer empty', () => { + const tmp = tmpdir() + expect(workerSpawnEnv('win32')).toEqual({ TMP: tmp, TEMP: tmp }) + expect(workerSpawnEnv('linux')).toEqual({}) + }) + + it('workerSpawnEnv forwards TSX_TSCONFIG_PATH when the snapshot harness pins it', () => { + const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + expect(workerSpawnEnv('linux', tsconfig)).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) + expect(workerSpawnEnv('win32', tsconfig)).toEqual({ + TMP: tmpdir(), + TEMP: tmpdir(), + TSX_TSCONFIG_PATH: tsconfig, + }) + }) + it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => { const { ctx, parent } = await setup() // The ACP snapshot harness runs the parent with its cwd OUTSIDE the @@ -589,10 +611,13 @@ describe('dsh-workflow-worker-thread', () => { try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { keys: Object.keys(proc.env), tsconfig: proc.env.TSX_TSCONFIG_PATH } + return { keys: Object.keys(proc.env).sort(), tsconfig: proc.env.TSX_TSCONFIG_PATH } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ keys: ['TSX_TSCONFIG_PATH'], tsconfig }) + const expectedKeys = process.platform === 'win32' + ? ['TEMP', 'TMP', 'TSX_TSCONFIG_PATH'] + : ['TSX_TSCONFIG_PATH'] + expect(result.value).toEqual({ keys: expectedKeys, tsconfig }) } finally { delete process.env.TSX_TSCONFIG_PATH delete process.env.WORKFLOW_ENV_CANARY diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b21718dde1..ecf3116f2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../vendor/timer + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../packages/context/agent-instructions '@deepseek-ai/dsh-agent-tool-presentation': specifier: workspace:^ version: link:../../packages/core/agent-tool-presentation @@ -165,9 +168,6 @@ importers: '@deepseek-ai/dsh-compaction-tool-result-pruner': specifier: workspace:^ version: link:../../packages/compaction/compaction-tool-result-pruner - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../packages/util/launch-environment '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../packages/fs/fs-local @@ -180,30 +180,33 @@ importers: '@deepseek-ai/dsh-headless': specifier: workspace:^ version: link:../../packages/bundle/headless - '@deepseek-ai/dsh-mcp-client': - specifier: workspace:^ - version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../packages/util/home-paths + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../packages/jobs/jobs-local + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../packages/util/launch-environment + '@deepseek-ai/dsh-mcp-client': + specifier: workspace:^ + version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-persona': specifier: workspace:^ version: link:../../packages/preset/persona '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../../packages/terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/shell/pwsh-local '@deepseek-ai/dsh-pwsh-sandbox': specifier: workspace:^ version: link:../../packages/shell/pwsh-sandbox + '@deepseek-ai/dsh-schedule': + specifier: workspace:^ + version: link:../../packages/schedule/schedule '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../packages/session/session-projection @@ -216,9 +219,12 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:^ version: link:../../packages/skill/skill-filesystem - '@deepseek-ai/dsh-jobs-local': + '@deepseek-ai/dsh-terminal': specifier: workspace:^ - version: link:../../packages/jobs/jobs-local + version: link:../../packages/terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-time-context': specifier: workspace:^ version: link:../../packages/context/time-context @@ -249,15 +255,18 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../packages/shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../packages/shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph - '@deepseek-ai/dsh-schedule': - specifier: workspace:^ - version: link:../../packages/schedule/schedule '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -270,9 +279,6 @@ importers: '@deepseek-ai/dsh-tool-subagent-control': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent-control - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../packages/todo/tool-todo @@ -288,9 +294,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:^ version: link:../../packages/workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../packages/context/agent-instructions commander: specifier: ^15.0.0 version: 15.0.0 @@ -304,12 +307,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent - '@deepseek-ai/dsh-host-frontend-static': - specifier: workspace:^ - version: link:../../packages/host/frontend-static '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-frontend-static': + specifier: workspace:^ + version: link:../../packages/host/frontend-static '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -425,6 +428,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:../packages/core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:* + version: link:../packages/context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:* version: link:../packages/core/agent-loop @@ -437,12 +443,6 @@ importers: '@deepseek-ai/dsh-attachment-local': specifier: workspace:* version: link:../packages/attachment/attachment-local - '@deepseek-ai/dsh-shell': - specifier: workspace:* - version: link:../packages/shell/shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:* - version: link:../packages/shell/shell-env '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/shell/bash-local @@ -503,9 +503,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:* version: link:../packages/runtime-diagnostics/invariants - '@deepseek-ai/dsh-sdk-jsonrpc-server': + '@deepseek-ai/dsh-jobs-local': specifier: workspace:* - version: link:../packages/sdk/server + version: link:../packages/jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../packages/llm/llm @@ -533,12 +533,6 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:* version: link:../packages/plan/plan-mode - '@deepseek-ai/dsh-terminal': - specifier: workspace:* - version: link:../packages/terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:* - version: link:../packages/terminal/terminal-bash '@deepseek-ai/dsh-pwsh-local': specifier: workspace:* version: link:../packages/shell/pwsh-local @@ -557,6 +551,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:* version: link:../packages/core/scope + '@deepseek-ai/dsh-sdk-jsonrpc-server': + specifier: workspace:* + version: link:../packages/sdk/server '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../packages/core/session @@ -590,6 +587,12 @@ importers: '@deepseek-ai/dsh-settings-file': specifier: workspace:* version: link:../packages/settings/settings-file + '@deepseek-ai/dsh-shell': + specifier: workspace:* + version: link:../packages/shell/shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:* + version: link:../packages/shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:* version: link:../packages/skill/skill @@ -632,15 +635,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../packages/core/system-prompt - '@deepseek-ai/dsh-jobs-local': + '@deepseek-ai/dsh-terminal': specifier: workspace:* - version: link:../packages/jobs/jobs-local + version: link:../packages/terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:* + version: link:../packages/terminal/terminal-bash '@deepseek-ai/dsh-time-context': specifier: workspace:* version: link:../packages/context/time-context - '@deepseek-ai/dsh-tool-call-timeout-policy': - specifier: workspace:* - version: link:../packages/guard/timeout-policy '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../packages/llm/token-meter @@ -653,6 +656,9 @@ importers: '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:* version: link:../packages/shell/tool-bash-persistent + '@deepseek-ai/dsh-tool-call-timeout-policy': + specifier: workspace:* + version: link:../packages/guard/timeout-policy '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../packages/extensions/tool-cordis @@ -665,12 +671,12 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:* version: link:../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:* + version: link:../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-lsp': specifier: workspace:* version: link:../packages/lsp/tool-lsp - '@deepseek-ai/dsh-tool-terminal': - specifier: workspace:* - version: link:../packages/terminal/tool-terminal '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:* version: link:../packages/shell/tool-pwsh @@ -695,9 +701,9 @@ importers: '@deepseek-ai/dsh-tool-subagent-report': specifier: workspace:* version: link:../packages/subagent/tool-subagent-report - '@deepseek-ai/dsh-tool-jobs': + '@deepseek-ai/dsh-tool-terminal': specifier: workspace:* - version: link:../packages/jobs/tool-jobs + version: link:../packages/terminal/tool-terminal '@deepseek-ai/dsh-tool-todo': specifier: workspace:* version: link:../packages/todo/tool-todo @@ -725,9 +731,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:* version: link:../packages/workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:* - version: link:../packages/context/agent-instructions native/landlock-run: devDependencies: @@ -898,299 +901,12 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../attachment - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - - packages/shell/shell: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - - packages/shell/shell-env: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session/session-persistence - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - - packages/shell/bash-local: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - - packages/shell/bash-sandbox: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-bash-local': - specifier: workspace:^ - version: link:../bash-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-local': - specifier: workspace:^ - version: link:../../sandbox/sandbox-local - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/node-addon-landlock-run': - specifier: workspace:* - version: link:../../../native/landlock-run/packages/entry - - packages/shell/pwsh-local: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - - packages/shell/pwsh-sandbox: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-pwsh-local': - specifier: workspace:^ - version: link:../pwsh-local - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-local': - specifier: workspace:^ - version: link:../../sandbox/sandbox-local - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - - packages/shell/tool-bash: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../shell-env - '@deepseek-ai/dsh-bash-local': - specifier: workspace:^ - version: link:../bash-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@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-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../session/session-persistence-jsonl - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../interaction/user-approval - - packages/shell/tool-pwsh: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../shell-env - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../test-support/loader-smoke - '@deepseek-ai/dsh-pwsh-local': - specifier: workspace:^ - version: link:../pwsh-local - '@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-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../interaction/user-approval packages/boot/app-boot: dependencies: @@ -1216,15 +932,15 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1264,6 +980,9 @@ importers: '@deepseek-ai/dsh-agent-default-model': specifier: workspace:^ version: link:../../core/agent-default-model + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -1273,9 +992,6 @@ importers: '@deepseek-ai/dsh-attachment-local': specifier: workspace:^ version: link:../../attachment/attachment-local - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../shell/shell-env '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ version: link:../../shell/bash-sandbox @@ -1315,6 +1031,9 @@ importers: '@deepseek-ai/dsh-goal-round-driver': specifier: workspace:^ version: link:../../goal/goal-round-driver + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1372,6 +1091,9 @@ importers: '@deepseek-ai/dsh-settings-file': specifier: workspace:^ version: link:../../settings/settings-file + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill @@ -1408,18 +1130,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-call-timeout-policy': - specifier: workspace:^ - version: link:../../guard/timeout-policy '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../shell/tool-bash + '@deepseek-ai/dsh-tool-call-timeout-policy': + specifier: workspace:^ + version: link:../../guard/timeout-policy '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../fs/tool-fs @@ -1429,6 +1148,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../shell/tool-pwsh @@ -1450,9 +1172,6 @@ importers: '@deepseek-ai/dsh-tool-subagent-report': specifier: workspace:^ version: link:../../subagent/tool-subagent-report - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo @@ -1486,9 +1205,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:^ version: link:../../workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../context/agent-instructions devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1578,57 +1294,54 @@ importers: '@deepseek-ai/dsh-client-ui-directory-picker-native': specifier: workspace:^ version: link:../../client/ui-directory-picker-native - '@deepseek-ai/dsh-client-ui-message-feedback': - specifier: workspace:^ - version: link:../../client/ui-message-feedback '@deepseek-ai/dsh-client-ui-goal': specifier: workspace:^ version: link:../../client/ui-goal + '@deepseek-ai/dsh-client-ui-input-trigger': + specifier: workspace:^ + version: link:../../client/ui-input-trigger + '@deepseek-ai/dsh-client-ui-jobs': + specifier: workspace:^ + version: link:../../client/ui-jobs '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../client/ui-layout + '@deepseek-ai/dsh-client-ui-message-feedback': + specifier: workspace:^ + version: link:../../client/ui-message-feedback '@deepseek-ai/dsh-client-ui-model-selection': specifier: workspace:^ version: link:../../client/ui-model-selection - '@deepseek-ai/dsh-client-ui-settings-models': - specifier: workspace:^ - version: link:../../client/ui-settings-models '@deepseek-ai/dsh-client-ui-permission-presets': specifier: workspace:^ version: link:../../client/ui-permission-presets '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../client/ui-plan - '@deepseek-ai/dsh-client-ui-settings-plugins': - specifier: workspace:^ - version: link:../../client/ui-settings-plugins - '@deepseek-ai/dsh-client-ui-settings-plugin-inventory': - specifier: workspace:^ - version: link:../../client/ui-settings-plugin-inventory - '@deepseek-ai/dsh-client-ui-user-questions': - specifier: workspace:^ - version: link:../../client/ui-user-questions '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../client/ui-settings '@deepseek-ai/dsh-client-ui-settings-general': specifier: workspace:^ version: link:../../client/ui-settings-general + '@deepseek-ai/dsh-client-ui-settings-models': + specifier: workspace:^ + version: link:../../client/ui-settings-models + '@deepseek-ai/dsh-client-ui-settings-plugin-inventory': + specifier: workspace:^ + version: link:../../client/ui-settings-plugin-inventory + '@deepseek-ai/dsh-client-ui-settings-plugins': + specifier: workspace:^ + version: link:../../client/ui-settings-plugins '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar '@deepseek-ai/dsh-client-ui-skill': specifier: workspace:^ version: link:../../client/ui-skill - '@deepseek-ai/dsh-client-ui-input-trigger': - specifier: workspace:^ - version: link:../../client/ui-input-trigger '@deepseek-ai/dsh-client-ui-subagent': specifier: workspace:^ version: link:../../client/ui-subagent - '@deepseek-ai/dsh-client-ui-jobs': - specifier: workspace:^ - version: link:../../client/ui-jobs '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../client/ui-theme @@ -1638,6 +1351,9 @@ importers: '@deepseek-ai/dsh-client-ui-trajectory': specifier: workspace:^ version: link:../../client/ui-trajectory + '@deepseek-ai/dsh-client-ui-user-questions': + specifier: workspace:^ + version: link:../../client/ui-user-questions '@deepseek-ai/dsh-client-ui-workflow-run': specifier: workspace:^ version: link:../../client/ui-workflow-run @@ -1650,12 +1366,6 @@ importers: '@deepseek-ai/dsh-code-runtime-worker-thread': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker-thread - '@deepseek-ai/dsh-web-frontend': - specifier: workspace:^ - version: link:../../../apps/web - '@deepseek-ai/dsh-host-frontend-static': - specifier: workspace:^ - version: link:../../host/frontend-static '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -1668,6 +1378,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker-native': specifier: workspace:^ version: link:../../host/directory-picker-native + '@deepseek-ai/dsh-host-frontend-static': + specifier: workspace:^ + version: link:../../host/frontend-static '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:^ version: link:../../host/plugin-inventory @@ -1695,6 +1408,9 @@ importers: '@deepseek-ai/dsh-storage-json': specifier: workspace:^ version: link:../../storage/storage-json + '@deepseek-ai/dsh-web-frontend': + specifier: workspace:^ + version: link:../../../apps/web '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace @@ -1711,12 +1427,12 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../shell/shell-env '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../shell/shell-env '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1921,49 +1637,6 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/test-support/client-runtime: - dependencies: - '@testing-library/dom': - specifier: ^10.4.1 - version: 10.4.1 - '@testing-library/react': - specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - vitest: - specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-web-react': - specifier: workspace:^ - version: link:../../client/web-react - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - '@types/react-dom': - specifier: ~18.3.0 - version: 18.3.7(@types/react@18.3.31) - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - packages/client/ui-agent-preset: devDependencies: '@deepseek-ai/cordis': @@ -2067,12 +1740,12 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2131,6 +1804,9 @@ importers: '@deepseek-ai/dsh-client-ui-attachment': specifier: workspace:^ version: link:../ui-attachment + '@deepseek-ai/dsh-client-ui-input-trigger': + specifier: workspace:^ + version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../ui-layout @@ -2140,9 +1816,6 @@ importers: '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-input-trigger': - specifier: workspace:^ - version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2299,6 +1972,149 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-goal: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-input-trigger: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-jobs: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + + packages/client/ui-layout: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../ui-theme + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-message-feedback: devDependencies: '@deepseek-ai/cordis': @@ -2350,81 +2166,6 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - packages/client/ui-goal: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../../interaction/commands - '@deepseek-ai/dsh-goal': - specifier: workspace:^ - version: link:../../goal/goal - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@testing-library/react': - specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - - packages/client/ui-layout: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-ui-theme': - specifier: workspace:^ - version: link:../ui-theme - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-model-selection: devDependencies: '@deepseek-ai/cordis': @@ -2451,12 +2192,12 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2473,51 +2214,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-settings-models: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-schema-form': - specifier: workspace:^ - version: link:../schema-form - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-web-react': - specifier: workspace:^ - version: link:../web-react - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-permission-presets: devDependencies: '@deepseek-ai/cordis': @@ -2544,15 +2240,15 @@ importers: '@deepseek-ai/dsh-client-ui-commands': specifier: workspace:^ version: link:../ui-commands + '@deepseek-ai/dsh-client-ui-input-trigger': + specifier: workspace:^ + version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-input-trigger': - specifier: workspace:^ - version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2614,94 +2310,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-settings-plugins: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-web-react': - specifier: workspace:^ - version: link:../web-react - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - - packages/client/ui-settings-plugin-inventory: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@testing-library/react': - specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - packages/client/ui-primitives: dependencies: '@shikijs/langs': @@ -2778,58 +2386,6 @@ importers: specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - packages/client/ui-user-questions: - dependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - clsx: - specifier: ^2.0.0 - version: 2.1.1 - react: - specifier: ^18.2.0 - version: 18.3.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-questions': - specifier: workspace:^ - version: link:../../interaction/user-questions - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - packages/client/ui-settings: dependencies: '@deepseek-ai/dsh-client-connection': @@ -2922,6 +2478,139 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-settings-models: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-schema-form': + specifier: workspace:^ + version: link:../schema-form + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-plugin-inventory: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-settings-plugins: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-sidebar: dependencies: clsx: @@ -2979,12 +2668,12 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3007,40 +2696,6 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - packages/client/ui-input-trigger: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-slots: devDependencies: '@deepseek-ai/cordis': @@ -3074,12 +2729,12 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3096,40 +2751,6 @@ importers: specifier: ~18.3.1 version: 18.3.31 - packages/client/ui-jobs: - dependencies: - react: - specifier: ^18.2.0 - version: 18.3.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - packages/client/ui-theme: dependencies: '@deepseek-ai/dsh-client-connection': @@ -3289,6 +2910,58 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-user-questions: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-questions': + specifier: workspace:^ + version: link:../../interaction/user-questions + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + packages/client/ui-workflow-run: dependencies: react: @@ -3614,6 +3287,55 @@ importers: specifier: workspace:^ version: link:../../llm/token-meter + packages/context/agent-instructions: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/context/session-reference: dependencies: '@deepseek-ai/schemastery': @@ -3694,71 +3416,22 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../../shell/shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - packages/context/agent-instructions: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-fs': - specifier: workspace:^ - version: link:../../fs/fs - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../fs/fs-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-llm-deepseek': - specifier: workspace:^ - version: link:../../llm/llm-deepseek - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tool-fs': - specifier: workspace:^ - version: link:../../fs/tool-fs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - packages/core/agent: devDependencies: '@deepseek-ai/cordis': @@ -3998,15 +3671,15 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment packages/e2b/e2b: dependencies: @@ -4088,6 +3761,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../context/agent-instructions '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../agent-spine-demo @@ -4115,9 +3791,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../context/agent-instructions packages/examples/agent-spine-demo: dependencies: @@ -4134,12 +3807,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../shell/shell-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../shell/bash-local @@ -4161,18 +3834,24 @@ importers: '@deepseek-ai/dsh-goal-round-driver': specifier: workspace:^ version: link:../../goal/goal-round-driver + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../llm/llm-retry - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local @@ -4188,6 +3867,9 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill @@ -4200,12 +3882,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../shell/tool-bash @@ -4215,18 +3891,15 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../goal/tool-goal - '@deepseek-ai/dsh-tool-skill': - specifier: workspace:^ - version: link:../../skill/tool-skill '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../skill/tool-skill '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../context/agent-instructions '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry @@ -4244,6 +3917,49 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/extensions/tool-cordis: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/cordis-plugin-timer': + specifier: workspace:^ + version: link:../../../vendor/timer + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/feedback/command-feedback: devDependencies: '@deepseek-ai/cordis': @@ -4258,6 +3974,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../identity/anonymous-user-id '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -4273,9 +3992,6 @@ importers: '@deepseek-ai/dsh-session-telemetry': specifier: workspace:^ version: link:../../session/session-telemetry - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../identity/anonymous-user-id packages/feedback/message-feedback: dependencies: @@ -4743,15 +4459,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell packages/hooks/hooks-claude-code: dependencies: @@ -4771,9 +4487,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../shell/bash-local @@ -4795,6 +4508,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -4823,9 +4539,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../shell/bash-local @@ -4847,6 +4560,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -4883,6 +4599,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -4916,9 +4635,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -5097,6 +4813,21 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/identity/anonymous-user-id: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/interaction/commands: dependencies: zod: @@ -5137,9 +4868,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../commands @@ -5161,6 +4889,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval @@ -5235,6 +4966,98 @@ importers: specifier: workspace:^ version: link:../../llm/llm + packages/jobs/jobs: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/jobs/jobs-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../jobs + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + + packages/jobs/tool-jobs: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../jobs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/llm/llm: dependencies: '@deepseek-ai/schemastery': @@ -5269,15 +5092,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../identity/anonymous-user-id '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -5287,9 +5113,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../identity/anonymous-user-id packages/llm/llm-pi-ai: dependencies: @@ -5309,12 +5132,12 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -5625,15 +5448,15 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -5672,25 +5495,7 @@ importers: specifier: workspace:^ version: link:../../core/system-prompt - packages/terminal/terminal: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - - packages/terminal/terminal-bash: + packages/runtime-diagnostics/invariants: dependencies: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery @@ -5699,143 +5504,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../terminal - '@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-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - - packages/shell/tool-bash-persistent: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../../terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../../terminal/terminal-bash - '@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-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - - packages/terminal/tool-terminal: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../terminal-bash - '@deepseek-ai/dsh-output-retention': - specifier: workspace:^ - version: link:../../util/output-retention - '@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-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools packages/sandbox/sandbox: devDependencies: @@ -6045,49 +5713,6 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent - packages/extensions/tool-cordis: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/cordis-plugin-timer': - specifier: workspace:^ - version: link:../../../vendor/timer - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - packages/session-query/session-log-download: devDependencies: '@deepseek-ai/cordis': @@ -6455,6 +6080,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../identity/anonymous-user-id '@deepseek-ai/dsh-command-feedback': specifier: workspace:^ version: link:../../feedback/command-feedback @@ -6470,9 +6098,6 @@ importers: '@deepseek-ai/dsh-session-telemetry': specifier: workspace:^ version: link:../session-telemetry - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../identity/anonymous-user-id packages/session/session-title: dependencies: @@ -6592,21 +6217,6 @@ importers: specifier: workspace:^ version: link:../../util/timeout - packages/identity/anonymous-user-id: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths - packages/settings/settings: dependencies: '@deepseek-ai/schemastery': @@ -6641,16 +6251,410 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../settings + packages/shell/bash-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + + packages/shell/bash-sandbox: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/node-addon-landlock-run': + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry + + packages/shell/pwsh-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + + packages/shell/pwsh-sandbox: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + + packages/shell/shell: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + + packages/shell/shell-env: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + + packages/shell/tool-bash: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local + '@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-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../shell-env + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval + + packages/shell/tool-bash-persistent: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@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-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../../terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../terminal/terminal-bash + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + + packages/shell/tool-pwsh: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../test-support/loader-smoke + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@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-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../shell-env + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval + + packages/shell/tool-pwsh-persistent: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@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-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../../terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../terminal/terminal-bash + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/skill/skill: dependencies: '@deepseek-ai/schemastery': @@ -6700,12 +6704,12 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -6902,6 +6906,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -6932,9 +6939,6 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -7278,6 +7282,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7299,12 +7309,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../jobs/tool-jobs @@ -7414,6 +7418,9 @@ importers: packages/subprocess/subprocess-local: dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 node-pty: specifier: ^1.1.0 version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) @@ -7434,6 +7441,122 @@ importers: specifier: workspace:^ version: link:../../util/timeout + packages/terminal/terminal: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/terminal/terminal-bash: + dependencies: + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../shell/pwsh-local + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@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-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../terminal + + packages/terminal/tool-terminal: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention + '@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-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../terminal-bash + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/test-support/acp-snapshot: dependencies: '@agentclientprotocol/sdk': @@ -7483,15 +7606,48 @@ importers: specifier: workspace:^ version: link:../../core/tools - packages/runtime-diagnostics/invariants: + packages/test-support/client-runtime: dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + vitest: + specifier: ^4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../../client/web-react + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../host/apiproxy + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/test-support/llm-mock-server: devDependencies: @@ -7545,98 +7701,6 @@ importers: specifier: workspace:^ version: link:../../core/session - packages/jobs/jobs: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - - packages/jobs/jobs-local: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../jobs - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - - packages/jobs/tool-jobs: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-output-retention': - specifier: workspace:^ - version: link:../../util/output-retention - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../jobs-local - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - packages/todo/tool-todo: dependencies: '@deepseek-ai/schemastery': @@ -7736,14 +7800,7 @@ importers: specifier: ^4.4.3 version: 4.4.3 - packages/typert/registry: - dependencies: - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../protocol - zod: - specifier: ^4.4.3 - version: 4.4.3 + packages/typert/protocol: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -7752,7 +7809,14 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/typert/protocol: + packages/typert/registry: + dependencies: + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../protocol + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -7779,6 +7843,15 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/util/home-paths: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/util/launch-environment: devDependencies: '@deepseek-ai/cordis': @@ -7797,15 +7870,6 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/util/home-paths: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - packages/util/output-retention: devDependencies: '@deepseek-ai/cordis': @@ -7932,12 +7996,12 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7957,12 +8021,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -7976,12 +8040,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -8199,24 +8263,24 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../packages/context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../packages/core/agent-loop '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../../packages/examples/agent-spine-demo + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../packages/identity/anonymous-user-id '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/boot/app-boot '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../packages/attachment/attachment - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../packages/shell/shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../packages/shell/shell-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/shell/bash-local @@ -8247,9 +8311,6 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../packages/credentials/credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../packages/util/launch-environment '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -8268,6 +8329,9 @@ importers: '@deepseek-ai/dsh-goal-round-driver': specifier: workspace:^ version: link:../../packages/goal/goal-round-driver + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../packages/util/home-paths '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../../packages/hooks/hook-protocol @@ -8280,12 +8344,15 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../packages/runtime-diagnostics/invariants - '@deepseek-ai/dsh-sdk-jsonrpc-server': + '@deepseek-ai/dsh-jobs': specifier: workspace:^ - version: link:../../packages/sdk/server - '@deepseek-ai/dsh-sdk-jsonrpc-demo': + version: link:../../packages/jobs/jobs + '@deepseek-ai/dsh-jobs-local': specifier: workspace:^ - version: link:../../packages/examples/jsonrpc-demo + version: link:../../packages/jobs/jobs-local + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../packages/util/launch-environment '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../packages/llm/llm @@ -8298,27 +8365,18 @@ importers: '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../packages/llm/llm-retry - '@deepseek-ai/dsh-home-paths': + '@deepseek-ai/dsh-output-retention': specifier: workspace:^ - version: link:../../packages/util/home-paths + version: link:../../packages/util/output-retention '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../packages/interaction/permission-presets '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../../packages/terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-repeat-tool-reminder': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-reminder - '@deepseek-ai/dsh-output-retention': - specifier: workspace:^ - version: link:../../packages/util/output-retention '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../packages/sandbox/sandbox @@ -8331,6 +8389,12 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../packages/core/scope + '@deepseek-ai/dsh-sdk-jsonrpc-demo': + specifier: workspace:^ + version: link:../../packages/examples/jsonrpc-demo + '@deepseek-ai/dsh-sdk-jsonrpc-server': + specifier: workspace:^ + version: link:../../packages/sdk/server '@deepseek-ai/dsh-sdk-protocol': specifier: workspace:^ version: link:../../packages/sdk/protocol @@ -8367,6 +8431,12 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../packages/settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../packages/shell/shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../packages/shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill @@ -8397,18 +8467,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt - '@deepseek-ai/dsh-jobs': + '@deepseek-ai/dsh-terminal': specifier: workspace:^ - version: link:../../packages/jobs/jobs - '@deepseek-ai/dsh-jobs-local': + version: link:../../packages/terminal/terminal + '@deepseek-ai/dsh-terminal-bash': specifier: workspace:^ - version: link:../../packages/jobs/jobs-local + version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../packages/util/timeout - '@deepseek-ai/dsh-tool-call-timeout-policy': - specifier: workspace:^ - version: link:../../packages/guard/timeout-policy '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../packages/llm/token-meter @@ -8421,6 +8488,9 @@ importers: '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../packages/shell/tool-bash-persistent + '@deepseek-ai/dsh-tool-call-timeout-policy': + specifier: workspace:^ + version: link:../../packages/guard/timeout-policy '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/extensions/tool-cordis @@ -8430,6 +8500,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -8442,9 +8515,6 @@ importers: '@deepseek-ai/dsh-tool-subagent-control': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent-control - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../packages/todo/tool-todo @@ -8463,9 +8533,6 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../packages/interaction/user-approval - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../packages/identity/anonymous-user-id '@deepseek-ai/dsh-user-questions': specifier: workspace:^ version: link:../../packages/interaction/user-questions @@ -8490,9 +8557,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:^ version: link:../../packages/workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../packages/context/agent-instructions '@deepseek-ai/schemastery': specifier: link:../../vendor/schemastery version: link:../../vendor/schemastery diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index e686081812..cc708b0227 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -281,11 +281,11 @@ const TOOL_PACKAGES: ToolPackage[] = [ { pkg: '@deepseek-ai/dsh-tool-pwsh-persistent', dir: 'tool-pwsh-persistent', - source: 'packages/pty/tool-pwsh-persistent/src/index.ts', - requires: ['ctx.tools', 'ctx.pty', 'an owning Agent at execution time'], + source: 'packages/shell/tool-pwsh-persistent/src/index.ts', + requires: ['ctx.tools', 'ctx.terminals', 'an owning Agent at execution time'], writes: ['tool/call', 'PTY shell state', 'tool/result'], async mount(ctx) { - await ctx.plugin(PtyService) + await ctx.plugin(TerminalSessionService) await ctx.plugin(ToolPwshPersistent) }, note: From 7da062f61bd16460193849ca4dfe9ee9c10f3422 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:15:35 +0800 Subject: [PATCH 030/180] docs: refresh catalogs, links, and pairs for the renamed persistent pwsh stack --- .../2026-08-11-pwsh-persistent-pty.i18n.yaml | 4 +-- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 33 ++++++++++++++++--- docs/config-catalog.zh.md | 33 ++++++++++++++++--- docs/tool-catalog.i18n.yaml | 4 +-- docs/tool-catalog.md | 26 +++++++++++++++ docs/tool-catalog.zh.md | 26 +++++++++++++++ packages/shell/tool-pwsh/README.i18n.yaml | 4 +-- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/terminal/terminal-bash/src/config.ts | 1 + 11 files changed, 121 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index 59ea231ed3..ce1e88c050 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages 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-pwsh-persistent-pty.md -2026-08-11-pwsh-persistent-pty.md: 7d4fe5e21fd4f9d96cfdf54dfbc6273f8aab3b45 -2026-08-11-pwsh-persistent-pty.zh.md: b1bb90218e15617d4445936abe1be19a537ef9f7 +2026-08-11-pwsh-persistent-pty.md: 092302ec001909683f9b7056e982309889c7f23e +2026-08-11-pwsh-persistent-pty.zh.md: 857f78c66f109ababe8a5961fa923d819857fd88 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d2a2391c44..cea7657b39 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: f7cfea66097cd8e2a9022a32d6459bd27ad9adcf -config-catalog.zh.md: ef1e19dc704fe14a6b1a41215573a22c9f7590a1 +config-catalog.md: 6ce672f7249e6784ec550860d2d9a5d3aa84d30d +config-catalog.zh.md: 3e1b370592a50a208bce7d9fa4b1d4b57726822d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f7cfea6609..6ce672f724 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2074,9 +2074,11 @@ Requires: `terminals` · `sandboxPolicy` · `subprocess` export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -2104,9 +2106,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -Source: [`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal/terminal-bash/src/config.ts) +Source: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ## `@deepseek-ai/dsh-time-context` @@ -2179,7 +2184,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:437`](../packages/shell/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2330,6 +2335,26 @@ export interface Config { Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +Requires: `tools` · `terminals` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflowEngine` · `subagents` · `systemPrompt` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ef1e19dc70..3e1b370592 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2076,9 +2076,11 @@ export interface Config { export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -2106,9 +2108,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -来源:[`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal/terminal-bash/src/config.ts) +来源:[`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ## `@deepseek-ai/dsh-time-context` @@ -2181,7 +2186,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) +来源:[`packages/shell/tool-bash-persistent/src/index.ts:437`](../packages/shell/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2331,6 +2336,26 @@ export interface Config { 来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +需要:`tools` · `terminals` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` 需要:`tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 12b34cd0f7..62ef725fbc 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: 1a28d560ab9fea3ca8e68856377e7912184d5311 -tool-catalog.zh.md: 1572c84f3b013e9a46198e1503d5373495845d28 +tool-catalog.md: f4fb7fe2a7ae65f779ae5ded146098deb2be9ad0 +tool-catalog.zh.md: 49ea51446e93c2224bd8f173e6f473e439f6100a diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 1a28d560ab..f4fb7fe2a7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal API. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (read_image registration)`, `ctx.llm + an image-capable route (read_image execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background jobs) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | @@ -352,6 +353,31 @@ Source: [`packages/shell/tool-bash-persistent/src/index.ts`](../packages/shell/t One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts`](../packages/shell/tool-pwsh-persistent/src/index.ts) + +One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 1572c84f3b..49ea51446e 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,6 +24,7 @@ | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`、`cordis_mount`、`cordis_unmount` | `ctx.tools` | `tool/call`、`tool/result`、`process-local temporary Plugin lifecycle` | - | 不在任何随产品发布的树中,需要有意选择启用;临时 Plugin 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。由 cordis_mount 创建的插件在卸载或 DSH 重启之前可以注册**额外的**模型可见工具;发生这类工具集变更时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | | `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (read_image registration)`、`ctx.llm + an image-capable route (read_image execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 | | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | @@ -354,6 +355,31 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +在持久 PowerShell shell 中运行命令。包括当前目录和已导出环境变量在内的状态会在此 agent 的多次调用之间保留。 + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts`](../packages/shell/tool-pwsh-persistent/src/index.ts) + +一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index 7dc6bee028..589769709c 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-pwsh/README.md -README.md: e146a8cf2e47cf827d7b83a5aad810e10017a018 -README.zh.md: 926b24e807a37dc4f49308e082256d64b7f38d38 +README.md: e862fcf0ca85d0ecb0a5fe6cff3ee3c7a8153716 +README.zh.md: e03a980acfe05583721a1f084cbf53545c076126 diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index e146a8cf2e..e862fcf0ca 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -121,6 +121,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. -- **No persistent shell** — every call starts a fresh `pwsh -Command`; the persistent-shell counterpart is [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md), which keeps one owner-scoped pwsh alive across calls on Windows (ConPTY) and POSIX hosts with pwsh. +- **No persistent shell** — every call starts a fresh `pwsh -Command`; the persistent-shell counterpart is [`@deepseek-ai/dsh-tool-pwsh-persistent`](../tool-pwsh-persistent/README.md), which keeps one owner-scoped pwsh alive across calls on Windows (ConPTY) and POSIX hosts with pwsh. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 926b24e807..e03a980acf 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -121,6 +121,6 @@ ack 是固定短行;任务输出按读取有界。 ## 已知限制与暂缓事项 - **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 -- **无持久 shell** — 每次调用都启动全新的 `pwsh -Command`;持久 shell 对应物是 [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md),它在 Windows(ConPTY)以及装有 pwsh 的 POSIX 主机上跨调用保持一个 owner 作用域的 pwsh 存活。 +- **无持久 shell** — 每次调用都启动全新的 `pwsh -Command`;持久 shell 对应物是 [`@deepseek-ai/dsh-tool-pwsh-persistent`](../tool-pwsh-persistent/README.md),它在 Windows(ConPTY)以及装有 pwsh 的 POSIX 主机上跨调用保持一个 owner 作用域的 pwsh 存活。 - **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index bf78a56361..5752845549 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -96,6 +96,7 @@ export const Config: z = z.object({ /** * Assert every effective numeric config field is a positive safe integer and bounds compose. * @param config - Schemastery-resolved plugin configuration. + * @returns Narrows the input to the fully resolved configuration. */ export function validateConfig(config: Config): asserts config is ResolvedConfig { const resolved = config as ResolvedConfig From 82c53ee209d164c91cc42bfa3eb7105149af94e1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:40:26 +0800 Subject: [PATCH 031/180] fix(terminal-bash): fall back to dialect defaults when Schemastery materializes empty shell values --- packages/terminal/terminal-bash/src/config.ts | 14 ++++++++++---- .../terminal/terminal-bash/tests/config.spec.ts | 11 +++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index 5752845549..19fada0c43 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -59,8 +59,10 @@ export const DEFAULT_PWSH_ARGS = ['-NoLogo', '-NoProfile'] /** * Resolve the effective per-dialect shell specification. Defaulting is this - * explicit step: an unset `shellPath`/`shellArgs` selects the dialect's - * defaults, while an explicit value always wins. + * explicit step: an unset or empty `shellPath`/`shellArgs` selects the + * dialect's defaults, while a non-empty explicit value always wins. + * (Schemastery materializes an absent optional array as `[]`, so emptiness — + * not just `undefined` — means "dialect default".) * @param config - Schemastery-resolved plugin configuration. * @returns the fully resolved configuration. */ @@ -69,8 +71,12 @@ export function resolveConfig(config: Config): ResolvedConfig { return { ...(config as Required), shellDialect, - shellPath: config.shellPath ?? (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL), - shellArgs: config.shellArgs ?? (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS), + shellPath: config.shellPath !== undefined && config.shellPath.length > 0 + ? config.shellPath + : (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL), + shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0 + ? config.shellArgs + : (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS), } } diff --git a/packages/terminal/terminal-bash/tests/config.spec.ts b/packages/terminal/terminal-bash/tests/config.spec.ts index 09a33c5ff9..d7557a2d90 100644 --- a/packages/terminal/terminal-bash/tests/config.spec.ts +++ b/packages/terminal/terminal-bash/tests/config.spec.ts @@ -54,6 +54,17 @@ describe('terminal-bash dialect resolution', () => { expect(resolved.shellArgs).toEqual(['-NoProfile']) }) + it('treats empty shell values as unset so Schemastery materialization cannot drop the dialect defaults', () => { + // Schemastery materializes an absent optional array as `[]`; the resolver + // must treat that shape like an unset value or a real bash spawn would + // start non-interactive without the controlled prompt. + const resolved = resolveConfig({ + backendType: 'shell', shellDialect: 'bash', shellPath: '', shellArgs: [], rows: 24, cols: 80, + }) + expect(resolved.shellPath).toBe('/bin/bash') + expect(resolved.shellArgs).toEqual(['--noprofile', '--norc', '-i']) + }) + it('validates the effective shell path, not only the raw one', () => { expect(() => { validateConfig(resolveConfig({ backendType: 'shell', shellDialect: 'bash', rows: 24, cols: 80 })) }).not.toThrow() expect(() => { validateConfig(resolveConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 })) }).not.toThrow() From 13d859a6199040e16a720fc559dd741a41cb487b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:47:44 +0800 Subject: [PATCH 032/180] test(subprocess): measure the pipe-drain settle from before the pid-file handoff --- packages/subprocess/subprocess-local/tests/spawn.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index ba214201be..1f21dfaaa1 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -857,8 +857,11 @@ describe('coverage seams', () => { ...spec('unused', { graceMs: 100 }), argv: [process.execPath, '-e', childScript], }) - const helper = await waitForPidFile(pidFile) + // The drain timer starts when the child's stdio closes, which can precede + // the pid file becoming visible; measure from before that wait so the + // lower bound cannot be eroded by the pid-file handoff. const started = Date.now() + const helper = await waitForPidFile(pidFile) const outcome = await running.done expect(outcome.exitCode).toBe(0) expect(Date.now() - started).toBeGreaterThanOrEqual(90) From 6e2a4fd08af25f60e4a193f98b5d79dfbca3685b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:54:23 +0800 Subject: [PATCH 033/180] test(terminal-bash): cover the spawn signal forwarded into the pwsh bootstrap send --- .../terminal-bash/tests/index.spec.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 571df73474..c1efd399d8 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -432,6 +432,39 @@ describe('BashTerminalBackend startup rollback', () => { const timedOut = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout')) await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') }) + + it('forwards the spawn signal into the pwsh bootstrap sends', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sends: TerminalSendRequest[] = [] + const session = { + motd: '', + startSend: (request: TerminalSendRequest) => { + sends.push(request) + return { + done: Promise.resolve({ + viewport: 'dsh> ', waitReason: 'stdin_read' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new BashTerminalBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async () => terminalHandle(), + () => session, + ) + const signal = new AbortController().signal + const spawned = await backend.spawn({ ...spec(agent(ctx)), signal }) + expect(spawned.motd).toBe('dsh> ') + expect(sends).toHaveLength(1) + expect(sends[0]?.signal).toBe(signal) + }) }) describe('terminal-bash plugin shape', () => { From b89808cc2d9e9a3c8240e1ac64bc43ff861ab0c8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 11:10:59 +0800 Subject: [PATCH 034/180] fix(gates): align package version, module graph, and jscpd ignores for the mirrored pwsh stack --- docs/module-graph.md | 7 +++++++ packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh-persistent/src/index.ts | 4 ++++ .../subprocess/subprocess-local/src/windows-inspector.ts | 4 ++++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index dc2ef337d9..3aa3efd323 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -282,6 +282,7 @@ flowchart TD pkg_tool_bash["tool-bash"] pkg_tool_bash_persistent["tool-bash-persistent"] pkg_tool_pwsh["tool-pwsh"] + pkg_tool_pwsh_persistent["tool-pwsh-persistent"] end subgraph group_storage["packages/storage"] pkg_storage["storage"] @@ -935,6 +936,11 @@ flowchart TD pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools + pkg_tool_pwsh_persistent --> pkg_agent + pkg_tool_pwsh_persistent --> pkg_invariants + pkg_tool_pwsh_persistent --> pkg_terminal + pkg_tool_pwsh_persistent --> pkg_timeout + pkg_tool_pwsh_persistent --> pkg_tools pkg_tool_terminal --> pkg_agent pkg_tool_terminal --> pkg_invariants pkg_tool_terminal --> pkg_jobs @@ -1538,6 +1544,7 @@ flowchart TD | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 5875376802..4f2fc37146 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index cce7ddd001..5fc2d3a03d 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -1,3 +1,5 @@ +/* jscpd:ignore-start -- deliberate mirror of tool-bash-persistent (persistent-pty note 2026-08-11-pwsh-persistent-pty): + the PowerShell counterpart shares the session registry, polling loop, and reset contract by design. */ /** * Model-facing persistent `pwsh` tool over the owner-scoped PTY seam. * @module @deepseek-ai/dsh-tool-pwsh-persistent @@ -508,3 +510,5 @@ export function apply(ctx: Context, config: Config): void { } registerPersistentPwsh(ctx, resolved) } + +/* jscpd:ignore-end */ diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index da5158b4c0..9c306f595f 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -38,6 +38,9 @@ export interface WindowsProcessInspectorInternals { * @param started - creation-time identity resolver for one member. * @returns the root and its current transitive descendants, children first. */ +/* jscpd:ignore-start -- the Windows inspector deliberately mirrors process-inspector.ts: + the decision logic (tree walk, identity fencing, group signalling) is the same contract over + Win32 primitives, per the persistent-pty note 2026-08-11-pwsh-persistent-pty. */ export function windowsProcessTree( entries: ProcessEntry[], rootPid: number, @@ -106,6 +109,7 @@ export class WindowsProcessInspector implements ProcessInspector { if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL') } } +/* jscpd:ignore-end */ /** * Create the Windows process inspector. From 68ba98c29e6f54a3a3c9ef5edffc56456ba9f970 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 11:24:43 +0800 Subject: [PATCH 035/180] docs: mirror the pwsh-persistent graph nodes and source lines into the Chinese counterparts --- 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.zh.md | 7 +++++++ 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index cea7657b39..717f0293c7 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: 6ce672f7249e6784ec550860d2d9a5d3aa84d30d -config-catalog.zh.md: 3e1b370592a50a208bce7d9fa4b1d4b57726822d +config-catalog.md: 09106e25600851705d102d2265354404f2369a8d +config-catalog.zh.md: 12d7487719a553e5152ca529b30540aa7c8eaafd diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6ce672f724..09106e2560 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2353,7 +2353,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 3e1b370592..12d7487719 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2354,7 +2354,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a2345a28c5..152ba8d0c7 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: dc2ef337d946b37d4d0a8600c70c26c53cc89dc0 -module-graph.zh.md: 8cd2feff369752cb0c9a9afd38a77f31ddf29826 +module-graph.md: 3aa3efd323836e48e79b22018f8c85a1869a22f2 +module-graph.zh.md: 438ac5208e3471ac11286a30cd82966f481a8de9 diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 8cd2feff36..438ac5208e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -284,6 +284,7 @@ flowchart TD pkg_tool_bash["tool-bash"] pkg_tool_bash_persistent["tool-bash-persistent"] pkg_tool_pwsh["tool-pwsh"] + pkg_tool_pwsh_persistent["tool-pwsh-persistent"] end subgraph group_storage["packages/storage"] pkg_storage["storage"] @@ -937,6 +938,11 @@ flowchart TD pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools + pkg_tool_pwsh_persistent --> pkg_agent + pkg_tool_pwsh_persistent --> pkg_invariants + pkg_tool_pwsh_persistent --> pkg_terminal + pkg_tool_pwsh_persistent --> pkg_timeout + pkg_tool_pwsh_persistent --> pkg_tools pkg_tool_terminal --> pkg_agent pkg_tool_terminal --> pkg_invariants pkg_tool_terminal --> pkg_jobs @@ -1540,6 +1546,7 @@ flowchart TD | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | From ebcf7d042333e34a8212dbcfc7346af4abf25479 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 14 Aug 2026 16:25:41 +0800 Subject: [PATCH 036/180] fix: preserve product identifiers across rescope --- scripts/rescope-vendor.ts | 29 +++++++++++++++++++++++++++++ tsconfig.base.json | 1 + 2 files changed, 30 insertions(+) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 3f5cb525c2..44b86c7e66 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -104,6 +104,35 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // GROUP_ORDER holds `packages//` directory names, not package names. { file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] }, { file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] }, + // `cordis/*` is the extensions event domain, not a package subpath. The + // generated catalogs and every producer/consumer must preserve that wire id. + { file: 'docs/event-producer-consumer.md', upstream: ['cordis'] }, + { file: 'docs/event-producer-consumer.zh.md', upstream: ['cordis'] }, + { file: 'docs/subsystems/extensions.md', upstream: ['cordis'] }, + { file: 'docs/subsystems/extensions.zh.md', upstream: ['cordis'] }, + { file: 'packages/api/remotes/src/remote-events.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-client-runner/src/client/index.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-client-runner/src/client/runtime.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/src/index.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/src/inspect-registry.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/src/types.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/tests/helpers.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/tests/runner.spec.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/tests/versioning.spec.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/tool-cordis/src/api-catalog.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/tool-cordis/src/providers.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/index.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/inventory.ts', upstream: ['cordis'] }, + { file: 'scripts/gen-cordis-catalog.ts', upstream: ['cordis'] }, + // The UI locale namespace and input-trigger source id are product keys. + { file: 'packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/CordisActionRow.tsx', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/CordisPanel.tsx', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/CordisRunRow.tsx', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/locales.ts', upstream: ['cordis'] }, ] /** A string that must appear exactly `count` times once the rescope has run. */ diff --git a/tsconfig.base.json b/tsconfig.base.json index 16d69db6db..ba80a3286f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -199,6 +199,7 @@ "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-workflow-run": ["./packages/client/ui-workflow-run/src"], "@deepseek-ai/dsh-client-ui-input-trigger": ["./packages/client/ui-input-trigger/src"], + "@deepseek-ai/dsh-client-ui-reference": ["./packages/client/ui-reference/src"], "@deepseek-ai/dsh-client-ui-commands": ["./packages/client/ui-commands/src"], "@deepseek-ai/dsh-client-ui-model-selection": ["./packages/client/ui-model-selection/src"], "@deepseek-ai/dsh-client-ui-goal": ["./packages/client/ui-goal/src"], From a4e2e1e6e987382a2038471981e86d3ef93c1b6d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 11:17:35 +0800 Subject: [PATCH 037/180] fix(gates): align tool-pwsh-persistent version and publish access with the rc.6 release --- packages/shell/tool-pwsh-persistent/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 4f2fc37146..c353f19bcd 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,9 +1,9 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.0.1-rc.2", + "version": "0.1.0-rc.6", "publishConfig": { - "access": "restricted" + "access": "public" }, "repository": { "type": "git", From f61e884917fac88c2f4d67588aa1382b6337c1f9 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 11:25:23 +0800 Subject: [PATCH 038/180] fix(gates): declare the MIT license for tool-pwsh-persistent --- packages/shell/tool-pwsh-persistent/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index c353f19bcd..84278ef05e 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -29,7 +29,7 @@ "lib/invariant.js", "lib/types/**/*.d.ts" ], - "license": "BSD-3-Clause", + "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", From 06b766711c9c7e8acd9ecb1b0b841f33d16a6114 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 11:45:13 +0800 Subject: [PATCH 039/180] fix(pty): pin UTF-8 output encodings in the persistent pwsh bootstrap --- .../terminal/terminal-bash/README.i18n.yaml | 4 +-- packages/terminal/terminal-bash/README.md | 4 +-- packages/terminal/terminal-bash/README.zh.md | 4 +-- packages/terminal/terminal-bash/src/index.ts | 14 ++++++---- .../terminal-bash/tests/index.spec.ts | 3 ++- .../terminal-bash/tests/local.spec.ts | 27 +++++++++++++++++++ 6 files changed, 44 insertions(+), 12 deletions(-) diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index 67afd7593c..2f648e4111 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/terminal/terminal-bash/README.md -README.md: ac8c4c8daf6db255bc55af3af49367f1de2c9a8a -README.zh.md: d10c7560173415281ad2820c5cb17ba3a7d4bf62 +README.md: 8b8c8293d7f2fbdc50b2311b1f327eb509578b72 +README.zh.md: 89f9323abc433ae9d212eb0b48ecd47760c635bc diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index ac8c4c8daf..8b8c8293d7 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -8,7 +8,7 @@ Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal` The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. +`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. That first send also prefixes the shared `dsh-pwsh-local` encoding preamble, pinning `[Console]::OutputEncoding` and `$OutputEncoding` to UTF-8 before anything runs: the session decode path reads PTY bytes as UTF-8, and an un-pinned console writes its host code page for non-ASCII output. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline. @@ -34,6 +34,6 @@ A standing-policy change appends an owner-rendered superseding runtime-context s - Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported. - Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound. -- The pwsh `prompt` bootstrap writes through `[Console]::`, which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. +- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer. - Sessions do not survive harness process exit. diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index d10c756017..89f9323abc 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -8,7 +8,7 @@ 该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 -`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 +`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。同一条首发送还会带上共享的 `dsh-pwsh-local` 编码前缀,在一切运行之前把 `[Console]::OutputEncoding` 与 `$OutputEncoding` 钉为 UTF-8:会话解码路径按 UTF-8 读取 PTY 字节,未钉住编码的控制台会以宿主代码页输出非 ASCII 内容。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 @@ -34,6 +34,6 @@ - 输出按行规范化;不支持全屏备用缓冲区交互。 - 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 -- pwsh `prompt` 引导通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 +- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 8e207eeb8e..012a57aae0 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -12,6 +12,7 @@ import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local' import { type Config, type ResolvedConfig, resolveConfig, type ShellDialect, validateConfig } from './config.ts' import { LocalPtySession } from './session.ts' import { CONTROLLED_PROMPT } from './sanitize.ts' @@ -112,15 +113,18 @@ async function startupSession( // pwsh cannot install its prompt from the environment: write the prompt // function through the session and wait for the first marker prompt, // which is also the readiness contract of the bash initialize path. The - // banner-to-prompt gap can outlast the silence bound, so the wait loops - // over follow-up sends until the controlled prompt is actually visible - // (in the viewport or the retained scrollback when it landed between - // sends), bounded by the send deadline. + // first send also pins UTF-8 output (the shared pwsh-local preamble) + // before anything runs: the session decode path treats PTY bytes as + // UTF-8, and an un-pinned console writes its host code page for + // non-ASCII output. The banner-to-prompt gap can outlast the silence + // bound, so the wait loops over follow-up sends until the controlled + // prompt is actually visible (in the viewport or the retained scrollback + // when it landed between sends), bounded by the send deadline. let viewport = '' for (;;) { const first = viewport.length === 0 const operation = session.startSend({ - text: first ? PWSH_PROMPT_SETUP : '', + text: first ? ENCODING_PREAMBLE + PWSH_PROMPT_SETUP : '', submit: first, ...signal !== undefined ? { signal } : {}, }) diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index c1efd399d8..0445f1b6d2 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -11,6 +11,7 @@ import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-p import TerminalSessionService, { TerminalBackendCleanupError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import type { TerminalSendRequest, TerminalWaitReason } from '@deepseek-ai/dsh-terminal' import { BashTerminalBackend, PWSH_PROMPT_SETUP } from '@deepseek-ai/dsh-terminal-bash' +import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local' import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash' import type { ResolvedConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts' import type { LocalPtySession } from '@deepseek-ai/dsh-terminal-bash/src/session.ts' @@ -368,7 +369,7 @@ describe('BashTerminalBackend startup rollback', () => { () => session, ) expect(await backend.spawn(spec(agent(ctx)))).toBe(session) - expect(sent).toMatchObject({ text: PWSH_PROMPT_SETUP, submit: true }) + expect(sent).toMatchObject({ text: ENCODING_PREAMBLE + PWSH_PROMPT_SETUP, submit: true }) expect(session.motd).toBe('setup-echo dsh> ') expect(spawned?.env).toMatchObject({ TERM: 'dumb', NO_COLOR: '1', DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1', diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 232ef7d984..ac5ad5be58 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -294,4 +294,31 @@ describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => { else process.env.DSH_TEST_SECRET = previous } }, 30_000) + + it('pins UTF-8 output encoding so non-ASCII output survives the byte decode', async () => { + const { ctx, root, agent } = await harness('danger-full-access', { + idleSilenceMs: 300, + handoffGraceMs: 300, + timeoutMs: 8_000, + }, 'pwsh') + const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root }) + // The bootstrap itself must have pinned both encodings: the session byte + // decode is UTF-8, so an un-pinned console writing its host code page + // garbles every non-ASCII byte that follows. + const pinned = ctx.terminals.startSend(agent, created.sessionId, { + text: '"console=" + [Console]::OutputEncoding.WebName + " out=" + $OutputEncoding.WebName', + submit: true, + }) + const pinnedResult = await pinned.done + expect(pinnedResult.viewport).toContain('console=utf-8 out=utf-8') + // Char codes keep the submitted line ASCII-only, so the assertion is a + // pure output-decode check. + const sent = ctx.terminals.startSend(agent, created.sessionId, { + text: "[Console]::Write([char]0x4E2D + [char]0x6587 + ' encoding-ok')", + submit: true, + }) + const result = await sent.done + expect(result.viewport).toContain('中文 encoding-ok') + await ctx.terminals.kill(agent, created.sessionId) + }, 30_000) }) From c854749c34dc88a77e757b9d6e58d8694e594404 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 17:05:19 +0800 Subject: [PATCH 040/180] fix(pty): ship dsh-pwsh-local in the python runtime closure --- 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 474669d616..5aee0f646f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8509,6 +8509,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/shell/pwsh-local '@deepseek-ai/dsh-repeat-tool-reminder': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-reminder diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 89d4b81b22..116a36b35e 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", From 99151657c0bf580a76d164ecfddbd094dd1355c3 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 20:49:15 +0800 Subject: [PATCH 041/180] fix(pty): import resolvePwshPath from the pwsh-local package root --- packages/terminal/terminal-bash/src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index 19fada0c43..848fd8bf9a 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -1,7 +1,7 @@ /** Validated configuration for the local PTY backend. */ import z from '@deepseek-ai/schemastery' -import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' /** One supported interactive shell dialect. */ export type ShellDialect = 'bash' | 'pwsh' From 6bb79911ba1e666a2d0f0e285249923b87f1bd38 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 18:35:04 +0800 Subject: [PATCH 042/180] refactor(reference): serve discovery through typert Remote faces Replace the legacy reference.* API Proxy domain with @Remote methods on the owning services, following the typert gateway design master adopted on 2026-08-02 (message-feedback and plugin-inventory precedents): - FileReferenceService and SessionReferenceResolver extend TypertRemoteService; fileReferences/list and sessionReferenceResolver/candidates are unary Remote methods cancelled through the reserved trailing signal, and the candidates face attaches each candidate's canonical mention under the configured limit - move the wire types to type-only ./types subpaths (FileReferenceCandidate, SessionReferenceMentionCandidate) and export ./typert plus ./remote artifacts - mount both contributions in the api-remotes client assembly; ui-reference consumes ctx.remote instead of connection.api.references and registers zh/en locale dictionaries for its sections and labels - delete the reference.* routes, schemas, map rows, client stubs, and fixtures; the connection fixture serves the Remote endpoints instead - release deliverPrompt admission listeners when the agent is disposed with the prepared prompt still pending, and cover the reference-* RpcError codes in the schema spec - add the missing tsconfig paths for the /grammar and /types subpaths (clean- tree vitest could not resolve @deepseek-ai/dsh-file-reference/grammar) - regenerate the cordis catalog, capability seams, and event matrix; update the owning bilingual READMEs, Agent Notes, and the reference-composer golden --- ...6-07-21-cross-session-references.i18n.yaml | 4 +- .../2026-07-21-cross-session-references.md | 2 +- .../2026-07-21-cross-session-references.zh.md | 2 +- ...-web-file-and-session-references.i18n.yaml | 4 +- ...6-07-27-web-file-and-session-references.md | 14 +- ...7-27-web-file-and-session-references.zh.md | 14 +- apps/web/tests/reference-composer.e2e.ts | 4 +- .../reference-composer/menu.expected.md | 4 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 3 +- docs/capability-seams.zh.md | 3 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/session-reference.i18n.yaml | 4 +- docs/subsystems/session-reference.md | 37 ++- docs/subsystems/session-reference.zh.md | 37 ++- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 2 +- docs/subsystems/typert.zh.md | 2 +- knip.json | 10 + packages/api/remotes/package.json | 20 +- packages/api/remotes/src/client/index.ts | 12 +- packages/api/remotes/tsconfig.client.json | 6 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + packages/client/connection/src/client/api.ts | 1 - .../client/connection/src/client/fixture.ts | 87 ++++--- .../client/connection/src/client/index.ts | 1 - .../connection/tests/fake-api.client.ts | 5 - .../client/runtime/tests/fake-api.client.ts | 5 - packages/client/ui-reference/README.i18n.yaml | 4 +- packages/client/ui-reference/README.md | 4 +- packages/client/ui-reference/README.zh.md | 4 +- packages/client/ui-reference/package.json | 15 +- .../client/ui-reference/src/client/index.ts | 72 ++++-- .../client/ui-reference/src/client/locales.ts | 34 +++ .../tests/browser-plugin.client.spec.ts | 235 ++++++++---------- packages/client/ui-reference/tsconfig.json | 22 +- packages/context/README.i18n.yaml | 4 +- packages/context/README.md | 4 +- packages/context/README.zh.md | 4 +- .../context/file-reference/README.i18n.yaml | 4 +- packages/context/file-reference/README.md | 2 +- packages/context/file-reference/README.zh.md | 2 +- packages/context/file-reference/package.json | 23 +- .../context/file-reference/src/grammar.ts | 2 +- packages/context/file-reference/src/index.ts | 32 ++- packages/context/file-reference/src/types.ts | 13 + .../file-reference/tests/service.spec.ts | 21 ++ .../session-reference/README.i18n.yaml | 4 +- packages/context/session-reference/README.md | 2 +- .../context/session-reference/README.zh.md | 2 +- .../context/session-reference/package.json | 24 +- .../context/session-reference/src/index.ts | 33 ++- .../context/session-reference/src/types.ts | 18 +- .../tests/session-reference.spec.ts | 18 ++ .../extensions/tool-cordis/src/api-catalog.ts | 16 ++ packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 - packages/host/apiproxy/README.zh.md | 2 - packages/host/apiproxy/package.json | 4 +- packages/host/apiproxy/src/api-proxy.ts | 88 +------ packages/host/apiproxy/src/api/index.ts | 3 - .../apiproxy/src/api/references.schema.ts | 42 ---- packages/host/apiproxy/src/api/references.ts | 39 --- packages/host/apiproxy/src/api/rpc-map.ts | 3 - packages/host/apiproxy/src/fetch/client.ts | 15 -- packages/host/apiproxy/src/fetch/handler.ts | 9 - packages/host/apiproxy/src/index.ts | 2 - .../tests/api-proxy-references.spec.ts | 105 +++----- .../apiproxy/tests/client-handler.spec.ts | 6 - .../host/apiproxy/tests/fetch-carrier.spec.ts | 8 - .../host/apiproxy/tests/rpc-schemas.spec.ts | 5 + packages/host/apiproxy/tsconfig.json | 3 - pnpm-lock.yaml | 34 ++- scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 3 +- scripts/type-equiv.manifest.json | 7 +- tsconfig.base.json | 3 + 81 files changed, 715 insertions(+), 596 deletions(-) create mode 100644 packages/client/ui-reference/src/client/locales.ts create mode 100644 packages/context/file-reference/src/types.ts create mode 100644 packages/context/file-reference/tests/service.spec.ts delete mode 100644 packages/host/apiproxy/src/api/references.schema.ts delete mode 100644 packages/host/apiproxy/src/api/references.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index b07c28f618..0cc877effb 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages 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-cross-session-references.md -2026-07-21-cross-session-references.md: eaa8191a0e71edfc4ea056788e24f513e495cdce -2026-07-21-cross-session-references.zh.md: f9af2960d7905c91ea1cbdbb982ce7c0bf987aa2 +2026-07-21-cross-session-references.md: d9aaaca442ba939b4221351e0df3601294674587 +2026-07-21-cross-session-references.zh.md: 17c3f91225f5f5ae2c2b1a639cc9d3c1794a490b diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index eaa8191a0e..d9aaaca442 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -34,7 +34,7 @@ Reference preparation is not a new steering protocol and does not create a turn The unified Web `@` source combines session candidates with Host-backed file discovery. Session candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the request's cancellation signal, and session id, cwd, and mention labels escape external control characters while the canonical URI retains the original id. -Web exposes discovery and preparation through `reference.sessions` and `session.prompt`, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. The composer retains text and chips until preparation and enqueue succeed, restoring them unchanged after failure; replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON. +Web exposes discovery through the `sessionReferenceResolver/candidates` Remote method and preparation through `session.prompt`, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. The composer retains text and chips until preparation and enqueue succeed, restoring them unchanged after failure; replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index f9af2960d7..17c3f91225 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -34,7 +34,7 @@ Web 负责快照/直接消息事务,不扩展通用收件箱记录。交付 统一的 Web `@` source 把会话候选与 Host 支持的文件发现组合在一起。会话候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。查询遵循请求的取消信号;session id、cwd 和提及标签中的外部控制字符会被转义,但规范 URI 仍保留原始 id。 -Web 通过 `reference.sessions` 和 `session.prompt` 暴露发现与准备流程,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范提及标记支撑的原子 chip。composer 会保留文本和 chip,直到准备与入队均成功;失败后会原样恢复。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 +Web 通过 `sessionReferenceResolver/candidates` Remote 方法暴露发现流程、通过 `session.prompt` 暴露准备流程,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范提及标记支撑的原子 chip。composer 会保留文本和 chip,直到准备与入队均成功;失败后会原样恢复。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml index 9016d377fb..fcd057ebd7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages 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-web-file-and-session-references.md -2026-07-27-web-file-and-session-references.md: dca1b6eb63eb96f54da85e8524f98018e7496f4b -2026-07-27-web-file-and-session-references.zh.md: 7c87a0aa54b4b528cb81967b818e815c8812ba8d +2026-07-27-web-file-and-session-references.md: 5032525d6362b1afa0da387c2dd3d5f2d961139f +2026-07-27-web-file-and-session-references.zh.md: fce6ec0d35b7712d2e966932144b468f88b67862 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md index dca1b6eb63..5032525d63 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -10,18 +10,18 @@ The Web composer had a reusable slash/reference trigger pipeline, but its `@` so ## Decision -Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Host lookups concurrently and deterministically orders files before sessions with stable labels; non-selectable `文件与文件夹` and `Session 对话` headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other. +Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Remote discovery calls concurrently and deterministically orders files before sessions with locale-registered labels; non-selectable file and session section headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other. -The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the Host RPC and shared grammar. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash. +The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the generated Remote namespaces and shared grammar. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash. -A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. `session.prompt` parses those mentions and calls `ctx.sessionReferences.prepare()` before delivery. Queued delivery binds the prepared context to the exact message id through a one-shot `agent/prompt-submit` wrapper and attaches it only to an allowed decision; steering delivery calls `inject()` immediately before `steer()`. Invalid mentions, cancellation, missing capability, source-read failure, and budget failure deliver nothing. +A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. `session.prompt` parses those mentions and calls `ctx.sessionReferenceResolver.prepare()` before delivery. Delivery binds the prepared context to the exact message id through a one-shot outer `agent/pre-step` listener that inserts the frozen snapshot immediately before that message on an enter decision; ordinary discard or agent disposal releases the listener, and queue-to-steer relocation preserves the pairing. Invalid mentions, cancellation, missing capability, source-read failure, and budget failure deliver nothing. -The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or RPC failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders adjacent metadata-confirmed session references as separate reference chips and preserves that projection when following text is adjacent without whitespace, plus a compact session-source summary instead of the snapshot JSON baked into model content. +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or Remote failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders adjacent metadata-confirmed session references as separate reference chips and preserves that projection when following text is adjacent without whitespace, plus a compact session-source summary instead of the snapshot JSON baked into model content. ## Reference transaction ```text -type @ → parallel file/session RPCs → pick path text or canonical session chip +type @ → parallel file/session Remote calls → pick path text or canonical session chip → serialize draft → Host parses and prepares all sessions → enqueue once ↘ any pre-enqueue failure: retain the unchanged editable draft ``` @@ -42,8 +42,8 @@ File lookup is advisory and cancellable; selection itself performs no read. Sess ## Verification -Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, Host wire validation, all-or-nothing prompt preparation, and draft retention across serialization and RPC failures. The keyless assembled Web snapshot renders the available reference sections, selects a file, then selects a session reference through the real client composition. +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, the owning services' Remote faces, all-or-nothing prompt preparation, and draft retention across serialization and Remote failures. The keyless assembled Web snapshot renders the available reference sections, selects a file, then selects a session reference through the real client composition. ## Consequences -Web now uses the shared `@file` discovery seam and structured session-reference identity, while Host services remain the authority for filesystem and session access. The new file-reference seam adds two packages and one Host RPC domain, but keeps browser bundles free of Node APIs and permits another provider to align completion with a remote filesystem. Candidate lookup failures remain quiet menu degradation; submission failures remain explicit and recoverable. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. +Web now uses the shared `@file` discovery seam and structured session-reference identity, while Host services remain the authority for filesystem and session access. The new file-reference seam adds two packages whose discovery methods are unary Remote contracts on the owning services, keeps browser bundles free of Node APIs, and permits another provider to align completion with a remote filesystem. Candidate lookup failures remain quiet menu degradation; submission failures remain explicit and recoverable. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md index 7c87a0aa54..fce6ec0d35 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -10,18 +10,18 @@ Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 ## 决策 -Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项宿主查询,以确定性顺序把文件排在会话之前,并使用稳定标签;不可选择的 `文件与文件夹` 和 `Session 对话` 标题会区分两个连续的候选分组,且不会进入键盘选择索引。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。 +Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项 Remote 发现调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的标签;不可选择的文件与会话分组标题会区分两个连续的候选分组,且不会进入键盘选择索引。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。 -文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费宿主 RPC 与共享语法。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。 +文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费生成的 Remote 命名空间与共享语法。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。 -选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` 提及标记。`session.prompt` 会解析这些提及标记,并在递送前调用 `ctx.sessionReferences.prepare()`。队列递送通过一次性 `agent/prompt-submit` 包装层把准备后的上下文绑定到精确消息 id,并且只为获准决策附加该上下文;steering 递送会紧邻 `steer()` 之前调用 `inject()`。无效提及标记、取消、功能缺失、读取源会话失败和预算失败都不会递送消息。 +选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` 提及标记。`session.prompt` 会解析这些提及标记,并在递送前调用 `ctx.sessionReferenceResolver.prepare()`。递送通过一次性的外层 `agent/pre-step` 监听器把准备后的上下文绑定到精确消息 id:进入决策时把冻结快照插入到该消息紧前,普通丢弃或 agent(智能体)销毁会释放监听器,队列转 steering 的迁移保持这一配对。无效提及标记、取消、功能缺失、读取源会话失败和预算失败都不会递送消息。 -输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 RPC 失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把相邻且经元数据确认的会话引用分别渲染为独立的引用 chip,即使后续文本无空白紧邻,也会保持该投影;同时显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 Remote 调用失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把相邻且经元数据确认的会话引用分别渲染为独立的引用 chip,即使后续文本无空白紧邻,也会保持该投影;同时显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 ## 引用事务 ```text -type @ → parallel file/session RPCs → pick path text or canonical session chip +type @ → parallel file/session Remote calls → pick path text or canonical session chip → serialize draft → Host parses and prepares all sessions → enqueue once ↘ any pre-enqueue failure: retain the unchanged editable draft ``` @@ -42,8 +42,8 @@ type @ → parallel file/session RPCs → pick path text or canonical session ch ## 验证 -包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、宿主协议校验、全有或全无的提示词准备,以及在序列化和 RPC 失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、所属服务的 Remote 调用面、全有或全无的提示词准备,以及在序列化和 Remote 调用失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 ## 后果 -Web 现在使用共享的 `@file` 发现 seam 和结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。新的文件引用 seam 增加了两个包和一个宿主 RPC 领域,但浏览器 bundle 中不包含 Node API,并允许其他提供方让补全与远程文件系统对齐。候选查询失败仍会让菜单静默降级;提交失败仍会显式报告且可恢复。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 +Web 现在使用共享的 `@file` 发现 seam 和结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。新的文件引用 seam 增加了两个包,其发现方法是所属服务上的一元 Remote 契约;浏览器 bundle 中不包含 Node API,并允许其他提供方让补全与远程文件系统对齐。候选查询失败仍会让菜单静默降级;提交失败仍会显式报告且可恢复。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index 8a1da34157..c67a975cbd 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -92,8 +92,8 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await expect.poll(() => menu.getByRole('option').count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(2) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) - expect(snapshot).toContain('\u6587\u4ef6\u4e0e\u6587\u4ef6\u5939') - expect(snapshot).toContain('Session \u5bf9\u8bdd') + expect(snapshot).toContain('Files & folders') + expect(snapshot).toContain('Session conversations') expect(snapshot).toContain('File \u00b7 reference.txt') expect(snapshot).toContain('Session \u00b7 Research notes') expect(snapshot).not.toContain('text: Subagents') diff --git a/apps/web/tests/snapshots/reference-composer/menu.expected.md b/apps/web/tests/snapshots/reference-composer/menu.expected.md index 1b2a95f426..f42be2dc47 100644 --- a/apps/web/tests/snapshots/reference-composer/menu.expected.md +++ b/apps/web/tests/snapshots/reference-composer/menu.expected.md @@ -1,5 +1,5 @@ - listbox "Trigger suggestions": - - text: reference 文件与文件夹 + - text: reference Files & folders - option "File · reference.txt reference.txt" [selected] - - text: Session 对话 + - text: Session conversations - option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}" diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index bcc81a9737..da9ced92e9 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: dddbb3822af6b0863c7f181e34f8eaa9ec68d87f -capability-seams.zh.md: 893d034b610ce9568ca88aa49b94e087fcff005d +capability-seams.md: 134f95f0efacafaf70017d61a8a1d468a79d4572 +capability-seams.zh.md: 48e880236298fc0b475fca9fb6d23aec549c059e diff --git a/docs/capability-seams.md b/docs/capability-seams.md index dddbb3822a..134f95f0ef 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -321,7 +321,6 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b - svc_fileReferences --> pkg_apiproxy svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -434,7 +433,7 @@ flowchart LR | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry. | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | -| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | `apiproxy` | - | The interface returns path-only completion candidates within the addressed Agent cwd; providers own namespace access and ranking without reading file contents. | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | The interface returns path-only completion candidates within the addressed Agent cwd through its unary Remote contract; providers own namespace access and ranking without reading file contents. | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-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-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 893d034b61..48e8802362 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -323,7 +323,6 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b - svc_fileReferences --> pkg_apiproxy svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -436,7 +435,7 @@ flowchart LR | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 | -| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | `apiproxy` | - | 该接口返回指定 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问和排序,但不会读取文件内容。 | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | 该接口通过其一元 Remote 契约返回指定 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问和排序,但不会读取文件内容。 | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-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-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f53cf3ea2f..4a61e2d00c 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: e6a99e2378634e04b3f649268c10bbff53967754 -event-producer-consumer.zh.md: 026fa2d9503e157500035149e7fd7c90e2c77355 +event-producer-consumer.md: f088251b46c66588bd932141df448b7312ac5c52 +event-producer-consumer.zh.md: ef2b5a45465f1a6ed985cc04496573b300fec606 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e6a99e2378..f088251b46 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,7 +10,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 026fa2d950..ef2b5a4546 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -12,7 +12,7 @@ | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index eadc4bdfef..535685927d 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: b21fb854c42f3199000617be0d6b2fb18f200b3e -session-reference.zh.md: 4bec1e1f5a7324d6687c77ca8cf03fe448650669 +session-reference.md: 2380584e8bdc70ac1fee28849c8d09f69d55fd9d +session-reference.zh.md: 974723e95d6f7f68d45ddb8efd65db3fa9470fc4 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index b21fb854c4..2380584e8b 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -4,7 +4,7 @@ English | [中文](session-reference.zh.md) Host-backed file discovery plus structured cross-session reference requests and prepared message contexts. The [file-reference contract](../../packages/context/file-reference) owns path-only completion records and grammar; the [session-reference 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. -Sources: [`packages/context/file-reference/src/index.ts`](../../packages/context/file-reference/src/index.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) +Sources: [`packages/context/file-reference/src/types.ts`](../../packages/context/file-reference/src/types.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) ## File candidates @@ -50,6 +50,16 @@ interface SessionReferenceCandidate { } ``` +The `sessionReferenceResolver/candidates` Remote method serves the same discovery to browser consumers and attaches each candidate's canonical prompt mention. + +```ts type-equiv +/** One discovery candidate carrying its canonical prompt mention. */ +interface SessionReferenceMentionCandidate extends SessionReferenceCandidate { + /** Canonical `@[label](dsh-session:…)` mention serialized into the prompt draft. */ + mention: string +} +``` + ## Prepared messages Preparation preserves readable current-message content and returns at most one aggregated context. @@ -103,11 +113,21 @@ Host capability for cancellable file-reference discovery. * @returns deterministic path-only candidates. */ abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Remote face of {@link list}; the decorator cannot mark the abstract + * member, so this concrete adapter carries the identical contract. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise ``` Types: [Agent](core.md) -Source: [`packages/context/file-reference/src/index.ts:32`](../../packages/context/file-reference/src/index.ts) +Source: [`packages/context/file-reference/src/index.ts:27`](../../packages/context/file-reference/src/index.ts) @@ -126,6 +146,17 @@ Exact-read consumer that prepares immutable cross-session message context. */ async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise +/** + * Remote face of {@link listCandidates}: the configured candidate limit + * applies, and every candidate carries the canonical mention a host inserts + * into the prompt draft. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd/title substring. + * @param signal - caller cancellation. + * @returns mention-carrying candidates in rank order. + */ +@Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise + /** * Snapshot all references before enqueue and return one aggregated durable context. * @param agent - target agent; references to it are rejected. @@ -139,5 +170,5 @@ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferen Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) -Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +Source: [`packages/context/session-reference/src/index.ts:75`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 4bec1e1f5a..974723e95d 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -4,7 +4,7 @@ 由 Host 支撑的文件发现,以及结构化的跨会话引用请求与准备后的消息上下文。[文件引用约定](../../packages/context/file-reference)负责仅含路径的补全记录与语法;[会话引用约定](../../packages/context/session-reference)定义规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 -来源:[`packages/context/file-reference/src/index.ts`](../../packages/context/file-reference/src/index.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) +来源:[`packages/context/file-reference/src/types.ts`](../../packages/context/file-reference/src/types.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) ## 文件候选项 @@ -50,6 +50,16 @@ interface SessionReferenceCandidate { } ``` +`sessionReferenceResolver/candidates` Remote 方法向浏览器消费方提供同一发现能力,并为每个候选附上规范提示词 mention。 + +```ts type-equiv +/** One discovery candidate carrying its canonical prompt mention. */ +interface SessionReferenceMentionCandidate extends SessionReferenceCandidate { + /** Canonical `@[label](dsh-session:…)` mention serialized into the prompt draft. */ + mention: string +} +``` + ## 准备后的消息 准备过程保留可读的当前消息内容,并最多返回一个聚合上下文。 @@ -103,11 +113,21 @@ Host capability for cancellable file-reference discovery. * @returns deterministic path-only candidates. */ abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Remote face of {@link list}; the decorator cannot mark the abstract + * member, so this concrete adapter carries the identical contract. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise ``` Types: [Agent](core.md) -Source: [`packages/context/file-reference/src/index.ts:32`](../../packages/context/file-reference/src/index.ts) +Source: [`packages/context/file-reference/src/index.ts:27`](../../packages/context/file-reference/src/index.ts) @@ -126,6 +146,17 @@ Exact-read consumer that prepares immutable cross-session message context. */ async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise +/** + * Remote face of {@link listCandidates}: the configured candidate limit + * applies, and every candidate carries the canonical mention a host inserts + * into the prompt draft. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd/title substring. + * @param signal - caller cancellation. + * @returns mention-carrying candidates in rank order. + */ +@Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise + /** * Snapshot all references before enqueue and return one aggregated durable context. * @param agent - target agent; references to it are rejected. @@ -139,5 +170,5 @@ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferen Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) -Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +Source: [`packages/context/session-reference/src/index.ts:75`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 7e1662a731..f0f042eecf 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/typert.md -typert.md: 5840a6e85f716f4a9adfdfc24395ffab03f5c480 -typert.zh.md: 0c274d13185408313ead71b7a870b119eba2879b +typert.md: 863ab9821bbf3681ae43df817cc04018e275390c +typert.zh.md: 4883b4be51a09d8e63001284e42bd7e60497bf1d diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 5840a6e85f..863ab9821b 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -248,7 +248,7 @@ Root interface of the unified API. New client-request domain = one new file pair respond(message: ClientResponse): Promise ``` -Source: [`packages/host/apiproxy/src/api/index.ts:23`](../../packages/host/apiproxy/src/api/index.ts) +Source: [`packages/host/apiproxy/src/api/index.ts:22`](../../packages/host/apiproxy/src/api/index.ts) diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 0c274d1318..4883b4be51 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -248,7 +248,7 @@ Root interface of the unified API. New client-request domain = one new file pair respond(message: ClientResponse): Promise ``` -Source: [`packages/host/apiproxy/src/api/index.ts:23`](../../packages/host/apiproxy/src/api/index.ts) +Source: [`packages/host/apiproxy/src/api/index.ts:22`](../../packages/host/apiproxy/src/api/index.ts) diff --git a/knip.json b/knip.json index 3017292382..977a42e3af 100644 --- a/knip.json +++ b/knip.json @@ -501,6 +501,16 @@ "tests/**/*.ts" ] }, + "packages/context/file-reference": { + "ignoreDependencies": [ + "zod" + ] + }, + "packages/context/session-reference": { + "ignoreDependencies": [ + "zod" + ] + }, "packages/session/session-checkpoint-policy": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index edc6d65704..c2059492b4 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -58,37 +58,41 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^" + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 8afe0aee68..6a5164f160 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -4,16 +4,20 @@ import type { Context } from '@deepseek-ai/cordis' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' +import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' +import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' export type { TypertClientRemote as ClientRemote } from '@deepseek-ai/dsh-typert-protocol' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-commands/remote' +export type {} from '@deepseek-ai/dsh-file-reference/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' +export type {} from '@deepseek-ai/dsh-session-reference/remote' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -39,7 +43,6 @@ export type { MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt, RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem, SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, - FileReferenceItem, SessionReferenceItem, SubagentAddress, SubagentCatalog, JobView, ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' @@ -87,6 +90,10 @@ export type { // reason: a Client contribution names what it sends without importing a Host // package, and this assembly is where both planes legitimately meet. export type { JsonValue } from '@deepseek-ai/dsh-session/types' +// Reference-discovery result vocabulary for the fileReferences and +// sessionReferenceResolver namespaces. +export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' declare module '@deepseek-ai/cordis' { interface Context { @@ -107,7 +114,8 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, + commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, + pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index a0161dd047..49c7276d42 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -24,6 +24,12 @@ "path": "../../credentials/credentials" }, + { + "path": "../../context/file-reference" + }, + { + "path": "../../context/session-reference" + }, { "path": "../../extensions/cordis-host-runner" }, diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 28cccce524..f92d47b80d 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: f22ae99113f7f313c60304ac4c5fc53c8e3172ab -README.zh.md: c4aa20c9f5f317bbe5dbb6f6731f6eb6d5d7ac7c +README.md: 8665f35590335358654e0e563bea7b57585e64ae +README.zh.md: 6222c3c2cb14c28af37daf82cdf39fd84f245f6d diff --git a/packages/client/README.md b/packages/client/README.md index f22ae99113..8665f35590 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -30,6 +30,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-commands/`](ui-commands/README.md) | Provides session-aware command discovery and dispatch. | | [`ui-input-trigger/`](ui-input-trigger/README.md) | Coordinates inline command and reference suggestions. | | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. | +| [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source. | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. | | [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs in the conversation header. | | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index c4aa20c9f5..6222c3c2cb 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -30,6 +30,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-commands/`](ui-commands/README.md) | 提供会话感知的命令发现与分发。 | | [`ui-input-trigger/`](ui-input-trigger/README.md) | 协调内联命令和引用建议。 | | [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 | +| [`ui-reference/`](ui-reference/README.md) | 统一的 Web `@file` / `@session` 引用 source。 | | [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent(子 agent)导航、子级 transcript(文本记录)的状态和内联引用。 | | [`ui-jobs/`](ui-jobs/README.md) | 在会话标题栏列出当前会话的后台任务。 | | [`ui-model-selection/`](ui-model-selection/README.md) | 在对话界面中提供模型选择。 | diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index b65eb61c79..1b7627b293 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -11,7 +11,6 @@ export type { DirectoryEntry, DirectoryListing, ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView, SkillsApi, SkillEntry, - ReferencesApi, FileReferenceItem, SessionReferenceItem, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, GoalsApi, GoalRef, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 67389e2745..f10329f0f2 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1824,6 +1824,51 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) /** Canonical fixture implementation of the generated Goal Remote contract. */ + /** Canonical fixture implementation of the generated reference-discovery Remote contracts. */ + const referenceRemotes = { + files(id: SessionId, query: string): RpcResult<{ path: string; kind: 'file' | 'directory' }[]> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const needle = query.toLocaleLowerCase() + const items = [ + { path: 'notes', kind: 'directory' as const }, + { path: 'README.md', kind: 'file' as const }, + { path: 'notes/demo.txt', kind: 'file' as const }, + ].filter(item => item.path.toLocaleLowerCase().includes(needle)) + return { ok: true, value: items } + }, + sessions(id: SessionId, query: string): RpcResult<{ + sessionId: SessionId + label: string + cwd?: string + createdAt: number + mention: string + }[]> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const needle = query.toLocaleLowerCase() + const value = sessions + .filter(item => item.sessionId !== id) + .filter(item => String(item.sessionId).toLocaleLowerCase().includes(needle) + || item.cwd?.toLocaleLowerCase().includes(needle) === true) + .map((item) => { + const label = item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId) + const encoded = btoa(JSON.stringify(item.sessionId)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, '') + return { + sessionId: item.sessionId, + label, + ...item.cwd === undefined ? {} : { cwd: item.cwd }, + createdAt: item.updatedAt, + mention: `@[${label}](dsh-session:${encoded})`, + } + }) + return { ok: true, value } + }, + } + const goalRemotes = { create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { const missing = requireGoalSession(id) @@ -2789,43 +2834,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) }, }, - references: { - files: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const query = request.payload.query.toLocaleLowerCase() - const items = [ - { path: 'notes', kind: 'directory' as const }, - { path: 'README.md', kind: 'file' as const }, - { path: 'notes/demo.txt', kind: 'file' as const }, - ].filter(item => item.path.toLocaleLowerCase().includes(query)) - return ok(request, { items }) - }, - sessions: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const query = request.payload.query.toLocaleLowerCase() - const items = sessions - .filter(item => item.sessionId !== request.payload.sessionId) - .filter(item => String(item.sessionId).toLocaleLowerCase().includes(query) - || item.cwd?.toLocaleLowerCase().includes(query) === true) - .map((item) => { - const label = item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId) - const encoded = btoa(JSON.stringify(item.sessionId)) - .replaceAll('+', '-') - .replaceAll('/', '_') - .replace(/=+$/u, '') - return { - sessionId: item.sessionId, - label, - ...item.cwd === undefined ? {} : { cwd: item.cwd }, - createdAt: item.updatedAt, - mention: `@[${label}](dsh-session:${encoded})`, - } - }) - return ok(request, { items }) - }, - }, goals: { // Compatibility face only: old API Proxy payloads and acknowledgements // adapt to the canonical fixture Remote implementation above. @@ -3041,6 +3049,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { args: { agentId: SessionId line?: string + query?: string ref?: { id: string; revision: number } request?: { objective?: string; maxGoalRounds?: number } } @@ -3049,6 +3058,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { switch (endpoint) { case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string)) + case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) + case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: args.request?.objective as string, ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, @@ -3143,8 +3154,6 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'skill.list': return this.api.skills.list(request) - case 'reference.files': return this.api.references.files(request, signal) - case 'reference.sessions': return this.api.references.sessions(request, signal) case 'agentPreset.list': return this.api.agentPresets.list(request) case 'agentPreset.select': return this.api.agentPresets.select(request) case 'agentPreset.read': return this.api.agentPresets.read(request) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 559c1ecfac..c4470b13aa 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -19,7 +19,6 @@ export type { DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, SkillsApi, SkillEntry, - ReferencesApi, FileReferenceItem, SessionReferenceItem, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index 4bcf1d4ef4..bee4fc0ce0 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -195,11 +195,6 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } - readonly references: IApiClient['references'] = { - files: (payload: unknown) => this.record('reference.files', payload, Promise.resolve(ok({ items: [] }))), - sessions: (payload: unknown) => this.record('reference.sessions', payload, Promise.resolve(ok({ items: [] }))), - } - readonly goals: IApiClient['goals'] = { create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), diff --git a/packages/client/runtime/tests/fake-api.client.ts b/packages/client/runtime/tests/fake-api.client.ts index c91d147827..33a0efbbfd 100644 --- a/packages/client/runtime/tests/fake-api.client.ts +++ b/packages/client/runtime/tests/fake-api.client.ts @@ -248,11 +248,6 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } - readonly references: IApiClient['references'] = { - files: (payload: unknown) => this.record('reference.files', payload, Promise.resolve(ok({ items: [] }))), - sessions: (payload: unknown) => this.record('reference.sessions', payload, Promise.resolve(ok({ items: [] }))), - } - readonly goals: IApiClient['goals'] = { create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index 6df31a89a3..a1c62f669c 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-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 packages/client/ui-reference/README.md -README.md: 7a96265f5db483569e3a3e6629303cb4b67a6b19 -README.zh.md: d991760bbbaf2e48e8f65447f9146a3f393c9db1 +README.md: c9ad377d2757a9dfea97c0660ca56cfb6beb6d20 +README.zh.md: 7e027abfc6237d7260fe00ff82af9a7103281804 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index 7a96265f5d..c9ad377d27 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Unified Web `@file` and `@session` source. The browser starts `reference.files` and `reference.sessions` Host RPCs together for an unquoted token, deterministically orders files before sessions with stable `Folder ·`, `File ·`, and `Session ·` labels, renders the rows under the non-selectable `文件与文件夹` and `Session 对话` headings, and degrades either failed candidate domain independently. An open `@"…` token searches files only. +Unified Web `@file` and `@session` source. The browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together for an unquoted token, deterministically orders files before sessions with locale-registered folder/file/session labels, renders the rows under non-selectable file and session section headings, and degrades either failed candidate domain independently. An open `@"…` token searches files only. File picks insert the natural text defined by the shared `@path` grammar. A file closes completion and adds a trailing space; a directory keeps the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted. @@ -20,6 +20,6 @@ Candidate browsing has no model effect. A selected file or session changes only ## Known Limitations and Deferred Work -- **Candidate failure is intentionally quiet** — one unavailable or failed reference RPC yields no rows for that domain, while prompt submission still reports session-reference preparation failures through the ordinary send path. +- **Candidate failure is intentionally quiet** — one unavailable or failed Remote discovery call yields no rows for that domain, while prompt submission still reports session-reference preparation failures through the ordinary send path. - **No browser-side file scan** — Web completion requires a mounted Host `ctx.fileReferences` provider; the browser cannot fall back to its own filesystem. - **Session search remains metadata-only** — discovery filters session id, cwd, and the latest log-backed title through `ctx.sessionReferenceResolver`; message bodies and full transcripts are not searched. diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index d991760bbb..7e027abfc6 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `reference.files` 和 `reference.sessions` 宿主 RPC,以确定性顺序把文件排在会话之前,并使用稳定的 `Folder ·`、`File ·` 和 `Session ·` 标签;各行分别渲染在不可选择的 `文件与文件夹` 和 `Session 对话` 标题下,任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。 +统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 和 `sessionReferenceResolver/candidates` Remote 调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签;各行分别渲染在不可选择的文件与会话分组标题下,任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。 选择文件会插入共享 `@path` 语法所定义的自然文本。文件会关闭补全并追加一个尾随空格;目录则让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。 @@ -20,6 +20,6 @@ ## 已知限制与暂缓事项 -- **候选失败有意保持静默**:引用 RPC 不可用或失败时,该领域不产生候选行;提示词提交仍会通过普通发送路径报告会话引用准备失败。 +- **候选失败有意保持静默**:Remote 发现调用不可用或失败时,该领域不产生候选行;提示词提交仍会通过普通发送路径报告会话引用准备失败。 - **浏览器侧不扫描文件**:Web 补全需要挂载宿主 `ctx.fileReferences` 提供方;浏览器无法回退到自身文件系统。 - **会话搜索仍仅使用元数据**:发现流程通过 `ctx.sessionReferenceResolver` 筛选 session id、cwd 和以日志为依据的最新标题;不搜索消息主体或完整 transcript(文本记录)。 diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 09d3f8edb2..f5133b54c5 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -32,8 +32,9 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-input-trigger" ], "platform": "web" @@ -46,18 +47,26 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "files": [ diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 18b6bd8607..f838083f63 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -1,47 +1,71 @@ /** * Unified Web `@` reference source. File and session discovery run through - * cancellable Host RPCs in parallel with deterministic ordering and labels. + * the cancellable generated Remote namespaces in parallel with deterministic + * ordering and labels. * * @module @deepseek-ai/dsh-client-ui-reference/client */ -import type { ConnectionHandle, FileReferenceItem, SessionReferenceItem } from '@deepseek-ai/dsh-api-remotes/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. +import type {} from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext, InputTriggerServiceContract, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { en, NS, zh, type ReferenceKey } from './locales.ts' -const FILE_SECTION = '文件与文件夹' -const SESSION_SECTION = 'Session 对话' +/** + * The two Remote calls this source needs. The generated face wraps every + * business result in {@link RemoteResult}: a carrier failure arrives as the + * `ok: false` branch rather than a rejection, so discovery reads one envelope + * per domain and either domain can fail without hiding the other. + */ +interface ReferenceRemotes { + readonly fileReferences: { + list: (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> + } + readonly sessionReferenceResolver: { + candidates: (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> + } +} -/** Required services: the slash registry and Host connection. */ -export const inject = ['inputTriggers', 'connection'] +/** Required services: the trigger registry, the Remote namespaces, and the copy. */ +export const inject = [ + 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', +] /** * Register the combined `@file` / `@session` source. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const references = (ctx.get('connection') as ConnectionHandle).api.references + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries') + const t = ctx.locale.bind(NS) + const remote: ReferenceRemotes = ctx.remote const source: InputTriggerSource = { trigger: '@', name: 'reference', async candidates(session: ClientSessionContext, { query, quoted, signal }) { - const files = references.files({ sessionId: session.sessionId, query }, signal).then( - response => response.result.ok ? response.result.value.items : [], + const files = remote.fileReferences.list(session.sessionId, query, signal).then( + result => result.ok ? result.value : [], () => [], ) const sessions = quoted === true - ? Promise.resolve([] as SessionReferenceItem[]) - : references.sessions({ sessionId: session.sessionId, query }, signal).then( - response => response.result.ok ? response.result.value.items : [], + ? Promise.resolve([] as SessionReferenceMentionCandidate[]) + : remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then( + result => result.ok ? result.value : [], () => [], ) const [fileItems, sessionItems] = await Promise.all([files, sessions]) if (signal.aborted) return [] return [ - ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true)), - ...sessionItems.map(sessionCandidate), + ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, t)), + ...sessionItems.map(candidate => sessionCandidate(candidate, t)), ] }, onPick({ candidate }) { @@ -73,11 +97,13 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => inputTriggers.registerSource(source), 'ui-reference: @ source') } +type Translate = (key: ReferenceKey) => string + type ReferenceCandidateValue = - | { kind: 'file'; fileKind: FileReferenceItem['kind']; mention: string } + | { kind: 'file'; fileKind: FileReferenceCandidate['kind']; mention: string } | { kind: 'session'; label: string; mention: string } -function fileCandidate(candidate: FileReferenceItem, preserveQuote: boolean) { +function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean, t: Translate) { const mention = formatFileMention(candidate, preserveQuote) if (mention === undefined) return [] const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) @@ -88,15 +114,15 @@ function fileCandidate(candidate: FileReferenceItem, preserveQuote: boolean) { mention, } return [{ - name: `${directory ? 'Folder' : 'File'} · ${name}${directory ? '/' : ''}`, + name: `${t(directory ? 'candidate.folder' : 'candidate.file')} · ${name}${directory ? '/' : ''}`, description: candidate.path, - section: FILE_SECTION, + section: t('section.files'), value: JSON.stringify(value), }] } -function sessionCandidate(candidate: SessionReferenceItem) { - const location = candidate.cwd ?? '(no cwd)' +function sessionCandidate(candidate: SessionReferenceMentionCandidate, t: Translate) { + const location = candidate.cwd ?? t('candidate.noCwd') const description = `${candidate.label === candidate.sessionId ? '' : `${candidate.sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` const value: ReferenceCandidateValue = { kind: 'session', @@ -104,9 +130,9 @@ function sessionCandidate(candidate: SessionReferenceItem) { mention: candidate.mention, } return { - name: `Session · ${candidate.label}`, + name: `${t('candidate.session')} · ${candidate.label}`, description, - section: SESSION_SECTION, + section: t('section.sessions'), value: JSON.stringify(value), } } diff --git a/packages/client/ui-reference/src/client/locales.ts b/packages/client/ui-reference/src/client/locales.ts new file mode 100644 index 0000000000..5ccb5574f8 --- /dev/null +++ b/packages/client/ui-reference/src/client/locales.ts @@ -0,0 +1,34 @@ +/** `reference` namespace dictionaries for the unified `@` source. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'reference' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'section.files': '文件与文件夹', + 'section.sessions': 'Session 对话', + 'candidate.file': '文件', + 'candidate.folder': '文件夹', + 'candidate.session': 'Session', + 'candidate.noCwd': '(无工作目录)', +} satisfies Record + +/** The reference namespace key union. */ +export type ReferenceKey = keyof typeof zh + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The unified `@` reference menu's copy. */ + reference: ReferenceKey + } +} + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'section.files': 'Files & folders', + 'section.sessions': 'Session conversations', + 'candidate.file': 'File', + 'candidate.folder': 'Folder', + 'candidate.session': 'Session', + 'candidate.noCwd': '(no cwd)', +} satisfies Record diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 78c7946039..11357fcc23 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -1,28 +1,31 @@ /** - * Web reference source coverage: Host-backed file/session discovery, + * Web reference source coverage: Remote-backed file/session discovery, * deterministic ordering and labels, quoted-path suppression, pick projections, codec * round-trip, and registration lifecycle. */ -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import type { FileReferenceItem, SessionReferenceItem } from '@deepseek-ai/dsh-api-remotes/client' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { CandidateRequest, ClientSessionContext, InputTriggerCandidate, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' import { apply, inject } from '../src/client/index.ts' const sid = (value: string): SessionId => value as SessionId const session: ClientSessionContext = { sessionId: sid('target') } -type ReferenceResponse = - | { result: { ok: true; value: { items: T[] } } } - | { result: { ok: false; error: { code: string; message: string } } } +type RemoteEnvelope = + | { ok: true; value: T } + | { ok: false; error: { code: string; message: string; details: object } } -type ReferenceLookup = ( - payload: unknown, +type RemoteLookup = ( + agentId: SessionId, + query: string, signal?: AbortSignal, -) => Promise> +) => Promise> function request( query: string, @@ -37,30 +40,22 @@ function request( } async function bench( - files: ReferenceLookup = vi.fn(() => Promise.resolve({ - result: { - ok: true as const, - value: { - items: [ - { path: 'src', kind: 'directory' as const }, - { path: 'docs/a b.md', kind: 'file' as const }, - ], - }, - }, + files: RemoteLookup = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [ + { path: 'src', kind: 'directory' as const }, + { path: 'docs/a b.md', kind: 'file' as const }, + ], })), - sessions: ReferenceLookup = vi.fn(() => Promise.resolve({ - result: { - ok: true as const, - value: { - items: [{ - sessionId: sid('source'), - label: 'Research', - cwd: '/project', - createdAt: 1_700_000_000_000, - mention: '@[Research](dsh-session:InNvdXJjZSI)', - }], - }, - }, + sessions: RemoteLookup = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 1_700_000_000_000, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], })), ): Promise<{ ctx: Context; fiber: ReturnType; source: InputTriggerSource }> { const ctx = new Context() @@ -71,7 +66,15 @@ async function bench( return () => { source = undefined } }, }) - ctx.provide('connection', { api: { references: { files, sessions } } } as never) + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + ctx.provide('remote.fileReferences', { list: files }) + ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) + ctx.provide('locale', new LocaleRuntime(ctx)) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() if (source === undefined) throw new Error('reference source was not registered') @@ -80,80 +83,74 @@ async function bench( describe('apply', () => { it('declares its services and releases the @ reference registration on disposal', async () => { - expect(inject).toEqual(['inputTriggers', 'connection']) - const ctx = new Context() + expect(inject).toEqual([ + 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', + ]) + const { fiber } = await bench() let registered: InputTriggerSource | undefined + const ctx = new Context() ctx.provide('inputTriggers', { registerSource(source: InputTriggerSource) { registered = source return () => { registered = undefined } }, }) - ctx.provide('connection', { - api: { - references: { - files: () => Promise.resolve({ result: { ok: true, value: { items: [] } } }), - sessions: () => Promise.resolve({ result: { ok: true, value: { items: [] } } }), - }, - }, - } as never) - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) }) + ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) }) + ctx.provide('locale', new LocaleRuntime(ctx)) + const ownFiber = ctx.plugin({ inject: [...inject], apply }) + await ownFiber.await() expect(registered).toMatchObject({ trigger: '@', name: 'reference' }) - await fiber.dispose() + await ownFiber.dispose() expect(registered).toBeUndefined() + await fiber.dispose() }) }) describe('candidates', () => { - it('starts both Host lookups together and renders files before sessions with stable labels', async () => { + it('starts both Remote lookups together and renders files before sessions with stable labels', async () => { let releaseFiles!: () => void let releaseSessions!: () => void const files = vi.fn(() => new Promise<{ - result: { ok: true; value: { items: { path: string; kind: 'file' | 'directory' }[] } } + ok: true + value: { path: string; kind: 'file' | 'directory' }[] }>((resolve) => { releaseFiles = () => { resolve({ - result: { - ok: true, - value: { - items: [ - { path: 'src', kind: 'directory' }, - { path: 'docs/a b.md', kind: 'file' }, - ], - }, - }, + ok: true, + value: [ + { path: 'src', kind: 'directory' }, + { path: 'docs/a b.md', kind: 'file' }, + ], }) } })) const sessions = vi.fn(() => new Promise<{ - result: { - ok: true - value: { - items: { - sessionId: SessionId - label: string - cwd: string - createdAt: number - mention: string - }[] - } - } + ok: true + value: { + sessionId: SessionId + label: string + cwd: string + createdAt: number + mention: string + }[] }>((resolve) => { releaseSessions = () => { resolve({ - result: { - ok: true, - value: { - items: [{ - sessionId: sid('source'), - label: 'Research', - cwd: '/project', - createdAt: 1_700_000_000_000, - mention: '@[Research](dsh-session:InNvdXJjZSI)', - }], - }, - }, + ok: true, + value: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 1_700_000_000_000, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], }) } })) @@ -165,12 +162,12 @@ describe('candidates', () => { releaseFiles() await expect(pending).resolves.toEqual([ expect.objectContaining({ - name: 'Folder · src/', + name: '文件夹 · src/', description: 'src', section: '文件与文件夹', }), expect.objectContaining({ - name: 'File · a b.md', + name: '文件 · a b.md', description: 'docs/a b.md', section: '文件与文件夹', }), @@ -185,29 +182,23 @@ describe('candidates', () => { it('suppresses sessions for an open quoted path and degrades each failed domain independently', async () => { const files = vi.fn() .mockResolvedValueOnce({ - result: { - ok: true as const, - value: { items: [{ path: 'README.md', kind: 'file' as const }] }, - }, + ok: true as const, + value: [{ path: 'README.md', kind: 'file' as const }], }) .mockRejectedValueOnce(new Error('file scan failed')) const sessions = vi.fn(() => Promise.resolve({ - result: { - ok: true as const, - value: { - items: [{ - sessionId: sid('source'), - label: 'Research', - cwd: '/project', - createdAt: 0, - mention: '@[Research](dsh-session:InNvdXJjZSI)', - }], - }, - }, + ok: true as const, + value: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 0, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], })) const { source } = await bench(files, sessions) const quoted = await source.candidates(session, request('READ', { quoted: true })) - expect(quoted).toEqual([expect.objectContaining({ name: 'File · README.md' })]) + expect(quoted).toEqual([expect.objectContaining({ name: '文件 · README.md' })]) expect(source.onPick({ candidate: quoted[0]!, session, @@ -229,55 +220,43 @@ describe('candidates', () => { await expect(pending).resolves.toEqual([]) }) - it('treats Host errors as empty domains and filters paths that cannot be mentioned', async () => { + it('treats Remote failures as empty domains and filters paths that cannot be mentioned', async () => { const files = vi.fn(() => Promise.resolve({ - result: { - ok: true as const, - value: { items: [{ path: 'bad\nname', kind: 'file' as const }] }, - }, + ok: true as const, + value: [{ path: 'bad\nname', kind: 'file' as const }], })) const sessions = vi.fn() .mockRejectedValueOnce(new Error('session lookup failed')) .mockResolvedValueOnce({ - result: { - ok: false as const, - error: { code: 'reference-failed', message: 'session lookup failed' }, - }, + ok: false as const, + error: { code: 'internal', message: 'session lookup failed', details: {} }, }) const { source } = await bench(files, sessions) await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) files.mockResolvedValueOnce({ - result: { - ok: false as const, - error: { code: 'reference-failed', message: 'file lookup failed' }, - }, + ok: false as const, + error: { code: 'internal', message: 'file lookup failed', details: {} }, } as never) await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) }) it('omits redundant session ids and labels sessions without a cwd', async () => { - const files = vi.fn(() => Promise.resolve({ - result: { ok: true as const, value: { items: [] } }, - })) + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) const sessions = vi.fn(() => Promise.resolve({ - result: { - ok: true as const, - value: { - items: [{ - sessionId: sid('same'), - label: 'same', - createdAt: 0, - mention: '@[same](dsh-session:InNhbWUi)', - }], - }, - }, + ok: true as const, + value: [{ + sessionId: sid('same'), + label: 'same', + createdAt: 0, + mention: '@[same](dsh-session:InNhbWUi)', + }], })) const { source } = await bench(files, sessions) await expect(source.candidates(session, request('same'))).resolves.toEqual([ expect.objectContaining({ name: 'Session · same', - description: '(no cwd) · 1970-01-01T00:00:00.000Z', + description: '(无工作目录) · 1970-01-01T00:00:00.000Z', }), ]) }) diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index 9e7c8c27e5..4fdd19dbc2 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -8,11 +8,26 @@ "src" ], "references": [ + { + "path": "../../../vendor/cordis" + }, { "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../../../vendor/cordis" + "path": "../../context/file-reference" + }, + { + "path": "../../context/session-reference" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../locale" }, { "path": "../runtime" @@ -21,10 +36,7 @@ "path": "../ui-input-trigger" }, { - "path": "../../context/file-reference" - }, - { - "path": "../../runtime-diagnostics/invariants" + "path": "../ui-slots" } ] } diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index 349b7f3784..fbfd8dfa06 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/README.md -README.md: fa28751e548dc4aec8e2e2711508816729f5d407 -README.zh.md: 9d12c7a854c6cb008c3806ea246ec5b5667be941 +README.md: ac1f6c302ce557adb71974c4d80b3b61e27f049c +README.zh.md: 98cc4ab9b3a3f84e9f8dfff0a91025ee697ba240 diff --git a/packages/context/README.md b/packages/context/README.md index fa28751e54..ac1f6c302c 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -2,11 +2,13 @@ English | [中文](README.zh.md) -Product plugins that add model-visible request context without defining a tool. `agent-instructions` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context`, `tmux-context`, and `session-reference` are opt-in. +Product plugins that add model-visible request context without defining a tool. `agent-instructions` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context`, `tmux-context`, `session-reference`, `file-reference`, and `file-reference-local` are opt-in. | Package | Role | ctx key | |---|---|---| | [`session-reference/`](session-reference/README.md) | Bounded snapshots of other sessions | `ctx.sessionReferenceResolver` | +| [`file-reference/`](file-reference/README.md) | File-reference discovery seam and `@file` grammar | `ctx.fileReferences` | +| [`file-reference-local/`](file-reference-local/README.md) | Local-filesystem file-reference provider | — | | [`time-context/`](time-context/README.md) | Current-time and elapsed-time context | — | | [`tmux-context/`](tmux-context/README.md) | tmux location context | — | | [`agent-instructions/`](agent-instructions/README.md) | Workspace-instruction context | — | diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index 9d12c7a854..98cc4ab9b3 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -2,11 +2,13 @@ [English](README.md) | 中文 -在不定义工具的情况下添加模型可见的请求上下文的产品插件。`agent-instructions` 包含在默认 `dsh-agent-spine-demo` 组合包中,可通过组合包配置禁用;`time-context`、`tmux-context` 和 `session-reference` 需主动启用。 +在不定义工具的情况下添加模型可见的请求上下文的产品插件。`agent-instructions` 包含在默认 `dsh-agent-spine-demo` 组合包中,可通过组合包配置禁用;`time-context`、`tmux-context`、`session-reference`、`file-reference` 和 `file-reference-local` 需主动启用。 | 包 | 职责 | ctx key | |---|---|---| | [`session-reference/`](session-reference/README.md) | 其他会话的有界快照 | `ctx.sessionReferenceResolver` | +| [`file-reference/`](file-reference/README.md) | 文件引用发现 seam 与 `@file` 语法 | `ctx.fileReferences` | +| [`file-reference-local/`](file-reference-local/README.md) | 本地文件系统文件引用提供方 | — | | [`time-context/`](time-context/README.md) | 当前时间与耗时上下文 | — | | [`tmux-context/`](tmux-context/README.md) | tmux 位置上下文 | — | | [`agent-instructions/`](agent-instructions/README.md) | 工作区指令上下文 | — | diff --git a/packages/context/file-reference/README.i18n.yaml b/packages/context/file-reference/README.i18n.yaml index de92c41665..352a6747ad 100644 --- a/packages/context/file-reference/README.i18n.yaml +++ b/packages/context/file-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 packages/context/file-reference/README.md -README.md: c65c787c2143ba88f2ac9887065b537c23f67ca2 -README.zh.md: 4c0d955bd6804f17ee99a5a65138f39391adfc95 +README.md: ae0e1ef68f927890af78767660cac094d831380c +README.zh.md: 1645dd345cd52cf5d3e02270c1fbd4c4344ada37 diff --git a/packages/context/file-reference/README.md b/packages/context/file-reference/README.md index c65c787c21..ae0e1ef68f 100644 --- a/packages/context/file-reference/README.md +++ b/packages/context/file-reference/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -File-reference discovery seam and browser-safe `@file` grammar shared by host-backed user interfaces. `ctx.fileReferences.list(agent, query, signal)` returns path-only file or directory candidates for the addressed agent; concrete providers own namespace access, ranking, caching, and invalidation. +File-reference discovery seam and browser-safe `@file` grammar shared by host-backed user interfaces. `ctx.fileReferences.list(agent, query, signal)` returns path-only file or directory candidates for the addressed agent; concrete providers own namespace access, ranking, caching, and invalidation. The same contract is remotely callable as the unary `fileReferences/list` Remote method (`@Remote` on the Service Definition, cancelled through the reserved trailing signal), so browser consumers call `ctx.remote.fileReferences.list` without an API Proxy route. `activeAtToken()` recognizes an `@path` or open `@"path with spaces` token only at the start of input or after whitespace, so email-like text does not open completion. `formatFileMention()` emits the matching prompt spelling, appends `/` to directory candidates, preserves an explicitly opened quote, and rejects control characters or embedded quotes that the editor grammar cannot represent safely. diff --git a/packages/context/file-reference/README.zh.md b/packages/context/file-reference/README.zh.md index 4c0d955bd6..1645dd345c 100644 --- a/packages/context/file-reference/README.zh.md +++ b/packages/context/file-reference/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -文件引用发现 seam,以及供宿主驱动的用户界面共享、可在浏览器中安全使用的 `@file` 语法。`ctx.fileReferences.list(agent, query, signal)` 为指定 agent(智能体)返回仅含路径的文件或目录候选;具体提供方负责命名空间访问、排序、缓存和失效处理。 +文件引用发现 seam,以及供宿主驱动的用户界面共享、可在浏览器中安全使用的 `@file` 语法。`ctx.fileReferences.list(agent, query, signal)` 为指定 agent(智能体)返回仅含路径的文件或目录候选;具体提供方负责命名空间访问、排序、缓存和失效处理。同一契约以一元 `fileReferences/list` Remote 方法对外可调(`@Remote` 标注在 Service Definition 上,经保留的末位 signal 参数取消),浏览器消费方直接调用 `ctx.remote.fileReferences.list`,无需 API Proxy 路由。 `activeAtToken()` 只在输入开头或空白后识别 `@path` 或尚未闭合的 `@"path with spaces` token,因此类似电子邮件的文本不会打开补全。`formatFileMention()` 会生成与提示词匹配的写法,为目录候选追加 `/`,保留显式打开的引号,并拒绝编辑器语法无法安全表示的控制字符或内嵌引号。 diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 0624bf331f..50ecf97ba1 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -26,6 +26,18 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -33,17 +45,26 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "zod": "^4.4.3" } } diff --git a/packages/context/file-reference/src/grammar.ts b/packages/context/file-reference/src/grammar.ts index 3e74248f32..86bb51a843 100644 --- a/packages/context/file-reference/src/grammar.ts +++ b/packages/context/file-reference/src/grammar.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-file-reference/grammar */ -import type { FileReferenceCandidate } from './index.ts' +import type { FileReferenceCandidate } from './types.ts' /** Active `@` token ending at the editor cursor. */ export interface ActiveAtToken { diff --git a/packages/context/file-reference/src/index.ts b/packages/context/file-reference/src/index.ts index f189416854..ca8c017236 100644 --- a/packages/context/file-reference/src/index.ts +++ b/packages/context/file-reference/src/index.ts @@ -4,24 +4,19 @@ * @module @deepseek-ai/dsh-file-reference */ -import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +import type { FileReferenceCandidate } from './types.ts' export { activeAtToken, formatFileMention } from './grammar.ts' export type { ActiveAtToken } from './grammar.ts' +export type { FileReferenceCandidate } from './types.ts' /** Model guidance for path-only references selected by a user interface. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' -/** One path-only completion candidate inside the target session cwd. */ -export interface FileReferenceCandidate { - /** User-facing path accepted by normal prompts and filesystem tools. */ - path: string - /** Directories keep completion open; files finish the mention. */ - kind: 'file' | 'directory' -} - declare module '@deepseek-ai/cordis' { interface Context { fileReferences: FileReferenceService @@ -29,7 +24,7 @@ declare module '@deepseek-ai/cordis' { } /** Host capability for cancellable file-reference discovery. */ -export abstract class FileReferenceService extends Service { +export abstract class FileReferenceService extends TypertRemoteService { constructor(ctx: Context) { super(ctx, 'fileReferences') } @@ -46,6 +41,23 @@ export abstract class FileReferenceService extends Service { query: string, signal: AbortSignal, ): Promise + + /** + * Remote face of {@link list}; the decorator cannot mark the abstract + * member, so this concrete adapter carries the identical contract. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ + @Remote('list') + remoteExportList( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise { + return this.list(agent, query, signal) + } } export default FileReferenceService diff --git a/packages/context/file-reference/src/types.ts b/packages/context/file-reference/src/types.ts new file mode 100644 index 0000000000..3637b2b2c4 --- /dev/null +++ b/packages/context/file-reference/src/types.ts @@ -0,0 +1,13 @@ +/** + * Public file-reference discovery records. This module contains types only so + * generated Remote clients can consume it without Host runtime code. + * @module @deepseek-ai/dsh-file-reference/types + */ + +/** One path-only completion candidate inside the target session cwd. */ +export interface FileReferenceCandidate { + /** User-facing path accepted by normal prompts and filesystem tools. */ + path: string + /** Directories keep completion open; files finish the mention. */ + kind: 'file' | 'directory' +} diff --git a/packages/context/file-reference/tests/service.spec.ts b/packages/context/file-reference/tests/service.spec.ts new file mode 100644 index 0000000000..1f41ad6f79 --- /dev/null +++ b/packages/context/file-reference/tests/service.spec.ts @@ -0,0 +1,21 @@ +/** The Remote face delegates to the provider's discovery contract unchanged. */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { FileReferenceService } from '../src/index.ts' +import type { FileReferenceCandidate } from '../src/types.ts' + +describe('FileReferenceService', () => { + it('serves the Remote face through the abstract discovery member', async () => { + const candidates: FileReferenceCandidate[] = [{ path: 'src', kind: 'directory' }] + const list = vi.fn((_agent: Agent, _query: string, _signal: AbortSignal) => Promise.resolve(candidates)) + class StubProvider extends FileReferenceService { + list = list + } + const provider = new StubProvider(new Context()) + const agent = { id: 'target' } as unknown as Agent + const signal = new AbortController().signal + await expect(provider.remoteExportList(agent, 'sr', signal)).resolves.toBe(candidates) + expect(list).toHaveBeenCalledWith(agent, 'sr', signal) + }) +}) diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index 2cffa62cc9..5a56be0add 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-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 packages/context/session-reference/README.md -README.md: cce2e19b949cba92dd58c31c9af0110d61fc66c1 -README.zh.md: 45deab310590909b239a92a63c049a5574c8b919 +README.md: 706838bfd475c320b589e091467b2974c9cc43ab +README.zh.md: 71e5fdca0755ea481b19aec79ad1e5ec9cf067fb diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index cce2e19b94..706838bfd4 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -6,7 +6,7 @@ English | [中文](README.zh.md) ## Public API -- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched. +- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched. The unary `sessionReferenceResolver/candidates` Remote method serves the same discovery under the configured candidate limit and attaches each candidate's canonical mention, so browser consumers call `ctx.remote.sessionReferenceResolver.candidates` without an API Proxy route. - `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated, identified `UserMessage` context. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`. - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text. diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index 45deab3105..71e5fdca07 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -6,7 +6,7 @@ ## 公开 API -- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或以日志为依据的最新标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label;标题不存在或无法读取时回退到会话 id。不搜索消息主体。 +- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或以日志为依据的最新标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label;标题不存在或无法读取时回退到会话 id。不搜索消息主体。一元 `sessionReferenceResolver/candidates` Remote 方法在配置的候选上限内提供同一发现能力,并为每个候选附上规范 mention,浏览器消费方直接调用 `ctx.remote.sessionReferenceResolver.candidates`,无需 API Proxy 路由。 - `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合且带标识的 `UserMessage` 上下文。任何无效引用、读取失败、取消或预算失败,都会使准备操作在宿主调用 `followup()` 或 `steer()` 之前失败。 - `encodeSessionReferenceUri()` 与 `decodeSessionReferenceUri()` 实现 `dsh-session:`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)`,`parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。解析器会拒绝显式 Markdown mention 中任何格式错误的 URI;只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。 diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 8d3e0ec487..e92a0204e8 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -22,17 +22,35 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -42,6 +60,7 @@ "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { @@ -52,6 +71,7 @@ "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 43bed833ef..b77ba8ce16 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -5,9 +5,10 @@ * @module @deepseek-ai/dsh-session-reference */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -21,7 +22,11 @@ import { } from './config.ts' import { retainReferencedSession, type ReferenceRetentionStats, type ReferencedSessionData } from './projection.ts' import { stringifyTagSafeJson } from './serialization.ts' -import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceSource } from './types.ts' +import type { + PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, + SessionReferenceMentionCandidate, SessionReferenceSource, +} from './types.ts' +import { formatSessionReferenceMention } from './uri.ts' export type * from './types.ts' export type { Config, SessionReferenceErrorCode } from './config.ts' @@ -67,7 +72,7 @@ interface RenderedSource { } /** Exact-read consumer that prepares immutable cross-session message context. */ -export class SessionReferenceResolver extends Service { +export class SessionReferenceResolver extends TypertRemoteService { static inject = ['sessionQuery'] static Config: z = z.object({ maxReferences: z.number().step(1).min(1).max(MAX_REFERENCES).default(MAX_REFERENCES), @@ -158,6 +163,28 @@ export class SessionReferenceResolver extends Service { })) } + /** + * Remote face of {@link listCandidates}: the configured candidate limit + * applies, and every candidate carries the canonical mention a host inserts + * into the prompt draft. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd/title substring. + * @param signal - caller cancellation. + * @returns mention-carrying candidates in rank order. + */ + @Remote('candidates') + async remoteExportCandidates( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise { + const candidates = await this.listCandidates(agent, query, this.config.candidateLimit, signal) + return candidates.map(candidate => ({ + ...candidate, + mention: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: candidate.label }), + })) + } + /** * Snapshot all references before enqueue and return one aggregated durable context. * @param agent - target agent; references to it are rejected. diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index e1eff84244..6d908f34b9 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -1,7 +1,13 @@ -/** Public session-reference request, candidate, and preparation records. */ +/** + * Public session-reference request, candidate, and preparation records. + * Imports stay on type-only subpaths so generated Remote clients can consume + * this module without Host runtime code. + * @module @deepseek-ai/dsh-session-reference/types + */ -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { UserMessage } from '@deepseek-ai/dsh-llm/message' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Durable source session, cited event seqs, and snapshot facts for prepared cross-session context. */ export interface SessionReferenceSource { @@ -49,6 +55,12 @@ export interface SessionReferenceCandidate { createdAt: number } +/** One discovery candidate carrying its canonical prompt mention. */ +export interface SessionReferenceMentionCandidate extends SessionReferenceCandidate { + /** Canonical `@[label](dsh-session:…)` mention serialized into the prompt draft. */ + mention: string +} + /** Direct message content and optional referenced-session context. */ export interface PreparedReferencedMessage { /** Readable message content after host mention tokens are removed. */ diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index a103554f23..d08b7da413 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -283,6 +283,24 @@ describe('session reference discovery and preparation', () => { listSessions.mockRestore() }) + it('serves the Remote face with the configured limit and canonical mentions', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same', createdAt: 10 } }) + ctx.sessions.create(SessionId('source]'), { meta: { cwd: '/same', createdAt: 20 } }) + const candidates = await ctx.sessionReferenceResolver.remoteExportCandidates( + fakeAgent(target), + '', + new AbortController().signal, + ) + expect(candidates).toEqual([{ + sessionId: SessionId('source]'), + label: 'source]', + cwd: '/same', + createdAt: 20, + mention: formatSessionReferenceMention({ sessionId: SessionId('source]'), label: 'source]' }), + }]) + }) + it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target')) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6200e431f4..3f51251b5f 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -573,6 +573,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'agent', description: 'target agent whose session cwd bounds discovery.' }, { name: 'query', description: 'path text following `@` or `@"`.' }, { name: 'signal', description: 'caller cancellation.' }], returns: 'deterministic path-only candidates.', }, + { + signature: '@Remote(\'list\') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise', + description: 'Remote face of list; the decorator cannot mark the abstract member, so this concrete adapter carries the identical contract.', + parameters: [{ name: 'agent', description: 'target agent whose session cwd bounds discovery.' }, { name: 'query', description: 'path text following `@` or `@"`.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'deterministic path-only candidates.', + }, ], }, { @@ -1269,6 +1275,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }], returns: 'candidates labeled by latest title or, when absent, session id.', }, + { + signature: '@Remote(\'candidates\') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise', + description: 'Remote face of listCandidates: the configured candidate limit applies, and every candidate carries the canonical mention a host inserts into the prompt draft.', + parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'mention-carrying candidates in rank order.', + }, { signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise', description: 'Snapshot all references before enqueue and return one aggregated durable context.', @@ -3878,6 +3890,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionReferenceInput', declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, + { + name: 'SessionReferenceMentionCandidate', + declaration: 'export interface SessionReferenceMentionCandidate extends SessionReferenceCandidate {\n mention: string;\n}', + }, { name: 'SessionResultFilter', declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 1629b11286..22fb19c837 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: d03714140589d5a10d2fcb308f1b1ad88d29d85e -README.zh.md: c2cd11abbb22540e14e3c7aa28acc2fc91c33466 +README.md: 69f10f1ed22d95e7c4601754957218df374a66cc +README.zh.md: 40179d51f8024105beafdc7c49e6eb8e70c7a224 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d037141405..69f10f1ed2 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -38,8 +38,6 @@ Session model selection is a session-domain contract. `session.models` returns t `session.prompt` and `subagent.prompt` accept optional request-local `clientTimeZone` provenance. When present, the Host validates and canonicalizes `UTC` or an IANA Area/Location before Agent entry, rejects invalid input with `invalid-time-zone`, and records the canonical value on that exact `user-rpc` message beside its `rpcId`. The value is not Session, connection, create, resume, or fork state; non-browser callers may omit it. -The `reference.*` domain addresses an ordinary session through the same ownership-fenced Agent lookup as other session controls. `reference.files` delegates cancellable path discovery to `ctx.fileReferences`; `reference.sessions` delegates metadata-only candidate ranking and Host-side canonical mention creation to `ctx.sessionReferenceResolver`. A missing capability returns `reference-unavailable`, while cancellation and provider failures remain distinguishable instead of becoming an authoritative empty list. - `session.prompt` parses canonical session mentions from normalized text blocks and asks `ctx.sessionReferenceResolver` to prepare every snapshot before enqueue. Parsing, cancellation, validation, reads, and budget enforcement are one admission transaction: failure enqueues no message, while success registers the separately sourced context against the readable prompt's exact message id for insertion immediately before it at pre-step entry. Ordinary discard removes the registration, and moving a queued prompt to steering preserves the association. 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. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c2cd11abbb..40179d51f8 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -38,8 +38,6 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.prompt` 和 `subagent.prompt` 接受可选的请求本地 `clientTimeZone` 来源信息。若提供该值,Host 会在进入 Agent 前校验 `UTC` 或 IANA Area/Location 并将其规范化;无效输入以 `invalid-time-zone` 拒绝,规范值则与 `rpcId` 一起记录在这条确切的 `user-rpc` 消息上。该值不属于 Session、连接、create、resume 或 fork 状态;非浏览器调用方可以省略它。 -`reference.*` 领域通过与其他会话控制相同、带所有权栅栏的 Agent 查找来寻址普通会话。`reference.files` 把可取消的路径发现委托给 `ctx.fileReferences`;`reference.sessions` 把仅基于元数据的候选排序与 Host 侧规范提及标记创建委托给 `ctx.sessionReferenceResolver`。缺少能力时返回 `reference-unavailable`;取消与提供方失败保持可区分,不会被折叠成看似权威的空列表。 - `session.prompt` 从规范化文本块中解析规范会话提及标记,并要求 `ctx.sessionReferenceResolver` 在入队前准备每个快照。解析、取消、校验、读取和预算约束共同构成一个准入事务:失败时不会有消息入队;成功时,会以可读提示词的确切消息 id 注册独立来源上下文,等待 pre-step 进入时紧邻插入到它之前。普通丢弃会移除该注册,把 queued 提示词移动到 steering(中途引导)时仍会保留两者的关联。 待处理的 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()` 修改队列。认领操作的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 43fd5fae1a..e3850b596a 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -51,7 +51,6 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", @@ -90,7 +89,6 @@ "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-typert-registry": "workspace:^" } } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a4265a9c74..037aa5d06c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -10,7 +10,6 @@ import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus, PreStepDecision } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' -import type {} from '@deepseek-ai/dsh-file-reference' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' @@ -20,7 +19,6 @@ import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { - formatSessionReferenceMention, parseSessionReferenceText, type SessionReferenceInput, } from '@deepseek-ai/dsh-session-reference' @@ -1121,6 +1119,7 @@ function deliverPrompt( let cleanedUp = false let detachPreStep = (): void => {} let detachDiscard = (): void => {} + let detachDisposed = (): void => {} const cleanup = (): void => { /* v8 ignore next -- all settlement paths share this idempotent release. */ if (cleanedUp) return @@ -1128,8 +1127,14 @@ function deliverPrompt( ownership.cleanups.delete(message.id) detachPreStep() detachDiscard() + detachDisposed() } ownership.cleanups.set(message.id, cleanup) + // An agent retired with the prepared prompt still pending must not leave + // these listeners on the Host root context for the process lifetime. + detachDisposed = ctx.on('agent/disposed', ({ agent: subject }) => { + if (subject === agent) cleanup() + }) detachPreStep = ctx.on('agent/pre-step', async ({ agent: subject, messages }, next): Promise => { if (subject !== agent || !messages.some(candidate => candidate.id === message.id)) return next() cleanup() @@ -3352,85 +3357,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, - references: { - async files(request, signal) { - const { sessionId, query } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const fileReferences = ctx.get('fileReferences') - if (fileReferences === undefined) { - return err(request, { - code: 'reference-unavailable', - message: 'file reference capability unavailable', - details: { kind: 'file' }, - }) - } - const effectiveSignal = signal ?? new AbortController().signal - try { - return ok(request, { - items: await fileReferences.list(found.agent, query, effectiveSignal), - }) - } catch (error: unknown) { - if (effectiveSignal.aborted) { - return err(request, { - code: 'cancelled', - message: 'file reference listing was aborted', - details: {}, - }) - } - return err(request, { - code: 'reference-failed', - message: 'file reference listing failed', - details: { reason: String(error) }, - }) - } - }, - - async sessions(request, signal) { - const { sessionId, query } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const sessionReferences = ctx.get('sessionReferenceResolver') - if (sessionReferences === undefined) { - return err(request, { - code: 'reference-unavailable', - message: 'session reference capability unavailable', - details: { kind: 'session' }, - }) - } - try { - const candidates = await sessionReferences.listCandidates( - found.agent, - query, - undefined, - signal, - ) - return ok(request, { - items: candidates.map(candidate => ({ - ...candidate, - mention: formatSessionReferenceMention({ - sessionId: candidate.sessionId, - label: candidate.label, - }), - })), - }) - } catch (error: unknown) { - if (signal?.aborted === true) { - return err(request, { - code: 'cancelled', - message: 'session reference listing was aborted', - details: {}, - }) - } - return err(request, { - code: 'reference-failed', - message: 'session reference listing failed', - details: { reason: String(error) }, - }) - } - }, - }, - settings: { describe(request) { const settings = ctx.get('settings') diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 875e597e7c..b5e1d1ffd9 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -9,7 +9,6 @@ import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' -import type { ReferencesApi } from './references.ts' import type { SubagentsApi } from './subagents.ts' import type { EventsApi } from './events.ts' import type { GoalsApi } from './goals.ts' @@ -26,7 +25,6 @@ export interface ApiProxy { host: HostApi workspace: WorkspaceApi skills: SkillsApi - references: ReferencesApi agentPresets: AgentPresetsApi events: EventsApi goals: GoalsApi @@ -57,7 +55,6 @@ export type { export type { JobView } from './jobs.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { SkillsApi, SkillEntry } from './skills.ts' -export type { FileReferenceItem, ReferencesApi, SessionReferenceItem } from './references.ts' export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { GoalsApi, GoalId, GoalRef } from './goals.ts' diff --git a/packages/host/apiproxy/src/api/references.schema.ts b/packages/host/apiproxy/src/api/references.schema.ts deleted file mode 100644 index 59bee90e09..0000000000 --- a/packages/host/apiproxy/src/api/references.schema.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Reference-domain wire schemas. - * - * @module @deepseek-ai/dsh-host-apiproxy/api/references.schema - */ - -import { z } from 'zod' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './sessions.schema.ts' - -const referenceRequestSchema = z.object({ - sessionId: sessionIdSchema, - query: z.string(), -}) - -/** reference.files request payload. */ -export const referenceFilesRequestSchema = referenceRequestSchema satisfies - z.ZodType>> - -/** reference.files response value. */ -export const referenceFilesValueSchema = z.object({ - items: z.array(z.object({ - path: z.string(), - kind: z.union([z.literal('file'), z.literal('directory')]), - })), -}) satisfies z.ZodType>> - -/** reference.sessions request payload. */ -export const referenceSessionsRequestSchema = referenceRequestSchema satisfies - z.ZodType>> - -/** reference.sessions response value. */ -export const referenceSessionsValueSchema = z.object({ - items: z.array(z.object({ - sessionId: sessionIdSchema, - label: z.string(), - cwd: z.string().optional(), - createdAt: z.number(), - mention: z.string(), - })), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/references.ts b/packages/host/apiproxy/src/api/references.ts deleted file mode 100644 index 13471f8d46..0000000000 --- a/packages/host/apiproxy/src/api/references.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Reference autocomplete domain contract. - * - * @module @deepseek-ai/dsh-host-apiproxy/api/references - */ - -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** One file or directory candidate inside the target session workspace. */ -export interface FileReferenceItem { - path: string - kind: 'file' | 'directory' -} - -/** One metadata-only cross-session reference candidate. */ -export interface SessionReferenceItem { - sessionId: SessionId - label: string - cwd?: string - createdAt: number - /** Canonical opaque mention serialized into the prompt draft. */ - mention: string -} - -/** Host-backed file and session reference discovery. */ -export interface ReferencesApi { - /** List path candidates using the target agent's workspace boundary. */ - files( - request: RpcRequest<{ sessionId: SessionId; query: string }>, - signal?: AbortSignal, - ): Promise> - - /** List cross-session candidates excluding the target session itself. */ - sessions( - request: RpcRequest<{ sessionId: SessionId; query: string }>, - signal?: AbortSignal, - ): Promise> -} diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index e34a7701df..80dede1799 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -9,7 +9,6 @@ import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' -import type { ReferencesApi } from './references.ts' import type { GoalsApi } from './goals.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' @@ -52,8 +51,6 @@ export interface RpcMethodMap { 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'workspace.archiveSession': WorkspaceApi['archiveSession'] 'skill.list': SkillsApi['list'] - 'reference.files': ReferencesApi['files'] - 'reference.sessions': ReferencesApi['sessions'] 'agentPreset.list': AgentPresetsApi['list'] 'agentPreset.select': AgentPresetsApi['select'] 'agentPreset.read': AgentPresetsApi['read'] diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 3fd94ffd4f..70e3ece58f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -41,10 +41,6 @@ import { workspaceRenameValueSchema, } from '../api/workspace.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' -import { - referenceFilesValueSchema, - referenceSessionsValueSchema, -} from '../api/references.schema.ts' import { agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema, @@ -128,10 +124,6 @@ export interface IApiClient { skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } - references: { - files(payload: RequestPayload<'reference.files'>, signal?: AbortSignal): Promise>> - sessions(payload: RequestPayload<'reference.sessions'>, signal?: AbortSignal): Promise>> - } agentPresets: { list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise>> select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise>> @@ -207,8 +199,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), } - readonly references: IApiClient['references'] = { - files: (payload, signal) => this.callUnary('reference.files', payload, signal), - sessions: (payload, signal) => this.callUnary('reference.sessions', payload, signal), - } - // Annotated like every sibling, and load-bearing rather than cosmetic: // inferring this member inlines `AgentPresetEntry` into the emitted // declaration by the specifier TS picks — the host `index.ts` — which drags diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 43643b2be1..d33e8dc55b 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -44,10 +44,6 @@ import { workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' -import { - referenceFilesRequestSchema, - referenceSessionsRequestSchema, -} from '../api/references.schema.ts' import { agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema, @@ -121,8 +117,6 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, - 'reference.files': { schema: referenceFilesRequestSchema, invoke: (api, r, signal) => api.references.files(r, signal) }, - 'reference.sessions': { schema: referenceSessionsRequestSchema, invoke: (api, r, signal) => api.references.sessions(r, signal) }, 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, 'agentPreset.read': { schema: agentPresetReadRequestSchema, invoke: (api, r) => api.agentPresets.read(r) }, @@ -178,9 +172,6 @@ function fullResponse(narrow: RpcResponse): Response { * Wire<> widening back to the exact payload (undefined-valued properties and * absent ones are indistinguishable after JSON transport). */ -// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own -// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection. -// oxlint-disable-next-line typescript/no-unnecessary-type-parameters async function handleUnary( api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal, ): Promise { diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index af8e064592..ac6c770801 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -85,7 +85,6 @@ export class ApiProxyService extends Service implements ApiProxy { readonly host: ApiProxy['host'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] - readonly references: ApiProxy['references'] readonly agentPresets: ApiProxy['agentPresets'] readonly settings: ApiProxy['settings'] readonly credentials: ApiProxy['credentials'] @@ -114,7 +113,6 @@ export class ApiProxyService extends Service implements ApiProxy { this.host = api.host this.goals = api.goals this.skills = api.skills - this.references = api.references this.agentPresets = api.agentPresets this.settings = api.settings this.credentials = api.credentials diff --git a/packages/host/apiproxy/tests/api-proxy-references.spec.ts b/packages/host/apiproxy/tests/api-proxy-references.spec.ts index 408584e26c..921440d7f2 100644 --- a/packages/host/apiproxy/tests/api-proxy-references.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-references.spec.ts @@ -1,7 +1,7 @@ /** - * Reference RPC coverage over the real ApiProxy: addressed Host discovery, - * canonical session mentions, atomic snapshot preparation before enqueue, - * and error/cancellation behavior. + * Referenced prompt coverage over the real ApiProxy: atomic snapshot + * preparation before enqueue and error/cancellation behavior. Discovery lives + * on the owning services' Remote faces, tested in their packages. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' @@ -86,67 +86,6 @@ function stubAgent(ctx: Context, status: Agent['status'] = 'idle') { return agent } -describe('reference discovery', () => { - it('addresses the target agent and returns file candidates unchanged', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const list = vi.fn(() => Promise.resolve([ - { path: 'src', kind: 'directory' as const }, - { path: 'src/index.ts', kind: 'file' as const }, - ])) - ctx.provide('fileReferences', { list } as never) - const api = createApiProxy(ctx, DEFAULTS) - const signal = new AbortController().signal - const value = expectOk(await api.references.files( - request({ sessionId: agent.id, query: 'sr' }), - signal, - )) - expect(value.items).toEqual([ - { path: 'src', kind: 'directory' }, - { path: 'src/index.ts', kind: 'file' }, - ]) - expect(list).toHaveBeenCalledWith(agent, 'sr', signal) - }) - - it('formats metadata candidates as opaque canonical mentions', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const source = 'source-session' as SessionId - const listCandidates = vi.fn(() => Promise.resolve([{ - sessionId: source, - label: 'Research]', - cwd: '/project', - createdAt: 42, - }])) - ctx.provide('sessionReferenceResolver', { listCandidates } as never) - const api = createApiProxy(ctx, DEFAULTS) - const value = expectOk(await api.references.sessions( - request({ sessionId: agent.id, query: 'res' }), - new AbortController().signal, - )) - expect(value.items).toEqual([{ - sessionId: source, - label: 'Research]', - cwd: '/project', - createdAt: 42, - mention: formatSessionReferenceMention({ sessionId: source, label: 'Research]' }), - }]) - expect(listCandidates).toHaveBeenCalledWith(agent, 'res', undefined, expect.any(AbortSignal)) - }) - - it('fails explicitly when a reference capability is not composed', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const api = createApiProxy(ctx, DEFAULTS) - expect(expectErr(await api.references.files( - request({ sessionId: agent.id, query: '' }), - )).code).toBe('reference-unavailable') - expect(expectErr(await api.references.sessions( - request({ sessionId: agent.id, query: '' }), - )).code).toBe('reference-unavailable') - }) -}) - describe('referenced prompt preparation', () => { it('normalizes the visible mention and waits for all context preparation before enqueue', async () => { const ctx = await harness() @@ -292,6 +231,44 @@ describe('referenced prompt preparation', () => { expect(agent.inject).not.toHaveBeenCalled() }) + it('releases the admission listeners when the agent is disposed with the prompt pending', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const source = 'source-session' as SessionId + const context = createUserMessage({ + source: { kind: 'plugin' as const, plugin: 'session-reference' }, + content: [{ type: 'text' as const, text: 'snapshot' }], + }) + ctx.provide('sessionReferenceResolver', { + prepare: () => Promise.resolve({ + content: [{ type: 'text' as const, text: 'continue @Research' }], + additionalContext: context, + }), + } as never) + const api = createApiProxy(ctx, DEFAULTS) + expectOk(await api.sessions.prompt(request({ + sessionId: agent.id, + content: [{ + type: 'text' as const, + text: `continue ${formatSessionReferenceMention({ sessionId: source, label: 'Research' })}`, + }], + mode: 'queue' as const, + }))) + const queued = agent.followup.mock.calls[0]?.[0] + if (queued === undefined) throw new Error('expected queued prompt') + + agentEvents(ctx, agent).emit('agent/disposed', { agent }) + + const signal = new AbortController().signal + const batch = agent.inbox.claim('next-turn', 1) + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: batch, turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: batch }), + ) + expect(decision).toEqual({ kind: 'enter', messages: [queued] }) + }) + it('keeps prepared context paired when a queued prompt moves to steering', async () => { const ctx = await harness() const agent = stubAgent(ctx, 'running') diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 7d1afc3650..4130d8f210 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -22,7 +22,6 @@ function scriptedApi(overrides: { subagents?: Partial host?: Partial skills?: Partial - references?: Partial agentPresets?: Partial events?: Partial goals?: Partial @@ -91,11 +90,6 @@ function scriptedApi(overrides: { archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, - references: { - files: r => ok(r, { items: [] }), - sessions: r => ok(r, { items: [] }), - ...overrides.references, - }, agentPresets: { list: r => ok(r, { presets: [], authorable: false, hasDocument: false }), select: r => ok(r, { agentPreset: r.payload.agentPreset }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 47d9382371..2000f708ba 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -223,14 +223,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } } }, }, - references: { - async files(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } - }, - async sessions(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } - }, - }, goals: { async create(request) { return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 66ae52f783..8902715c6b 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -73,6 +73,9 @@ describe('rpcErrorSchema', () => { details: { provider: 'p', model: 'm' }, }).code).toBe('model-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') + expect(rpcErrorSchema.parse({ code: 'reference-unavailable', message: 'm', details: { kind: 'session' } }).code).toBe('reference-unavailable') + expect(rpcErrorSchema.parse({ code: 'reference-invalid', message: 'm', details: { reason: 'r' } }).code).toBe('reference-invalid') + expect(rpcErrorSchema.parse({ code: 'reference-failed', message: 'm', details: { reason: 'r' } }).code).toBe('reference-failed') expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') @@ -84,6 +87,8 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'reference-unavailable', message: 'm', details: { kind: 'disk' } })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'reference-failed', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 9dc2ac1b78..6abd799c0e 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -62,9 +62,6 @@ { "path": "../../session-query/session-query" }, - { - "path": "../../context/file-reference" - }, { "path": "../../context/session-reference" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b862c8fcf2..0ce2a4ce80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -858,6 +858,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal @@ -879,6 +882,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -2424,18 +2430,30 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/client/ui-settings: dependencies: @@ -3385,6 +3403,10 @@ importers: version: link:../../core/tools packages/context/file-reference: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3395,6 +3417,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/context/file-reference-local: dependencies: @@ -3426,6 +3451,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3451,6 +3479,9 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/context/time-context: dependencies: @@ -4789,9 +4820,6 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-file-reference': - specifier: workspace:^ - version: link:../../context/file-reference '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4e2ca42b8d..0c56dd9953 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -271,6 +271,7 @@ export const LINK_MAP: Readonly> = { PreparedReferencedMessage: 'session-reference.md', FileReferenceCandidate: 'session-reference.md', SessionReferenceCandidate: 'session-reference.md', + SessionReferenceMentionCandidate: 'session-reference.md', SessionReferenceInput: 'session-reference.md', SessionEvent: 'session.md', SessionId: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 57d1f77609..e839ad0052 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -244,8 +244,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'File reference discovery', mode: 'seam', implementations: ['file-reference-local'], - consumers: ['apiproxy'], - note: 'The interface returns path-only completion candidates within the addressed Agent cwd; providers own namespace access and ranking without reading file contents.', + note: 'The interface returns path-only completion candidates within the addressed Agent cwd through its unary Remote contract; providers own namespace access and ranking without reading file contents.', }, { key: 'sessionReferenceResolver', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 423a099544..cbd01a6fc8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -668,13 +668,18 @@ { "doc": "docs/subsystems/session-reference.md", "symbol": "FileReferenceCandidate", - "source": "packages/context/file-reference/src/index.ts" + "source": "packages/context/file-reference/src/types.ts" }, { "doc": "docs/subsystems/session-reference.md", "symbol": "SessionReferenceCandidate", "source": "packages/context/session-reference/src/types.ts" }, + { + "doc": "docs/subsystems/session-reference.md", + "symbol": "SessionReferenceMentionCandidate", + "source": "packages/context/session-reference/src/types.ts" + }, { "doc": "docs/subsystems/session-reference.md", "symbol": "PreparedReferencedMessage", diff --git a/tsconfig.base.json b/tsconfig.base.json index ba80a3286f..5eae861962 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -87,6 +87,9 @@ "@deepseek-ai/dsh-api-remotes/invariant": ["./packages/api/remotes/src/invariant.ts"], "@deepseek-ai/dsh-compaction/checkpoint": ["./packages/compaction/compaction/src/checkpoint.ts"], "@deepseek-ai/dsh-compaction/types": ["./packages/compaction/compaction/src/types.ts"], + "@deepseek-ai/dsh-file-reference/grammar": ["./packages/context/file-reference/src/grammar.ts"], + "@deepseek-ai/dsh-file-reference/types": ["./packages/context/file-reference/src/types.ts"], + "@deepseek-ai/dsh-session-reference/types": ["./packages/context/session-reference/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-tools/types": ["./packages/core/tools/src/types.ts"], "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], From b19d1643f79424e4265810479c396350b81d590f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 18:35:45 +0800 Subject: [PATCH 043/180] fix(web): harden the composer submit transaction - wire the directory pick's continue flag through InsertTextRequest to the input shell, which re-tracks at the caret so descent reopens completion (the flag was produced and forwarded but never consumed) - guard the image-only send against a second Enter during the Host round-trip - keep text appended after the sent snapshot when a submit settles ok; only interleaved edits clear with the committed content - drop the dead restoreImages left from the sink rewrite - read recall labels through a shared defensive sessionRecallLabels helper instead of unchecked casts over durable log data, and take the reference summary separator from the locale dictionary - align the composer specs merged from master with the transactional submit contract (sinks resolve SubmitOutcome, settlement is awaited, call assertions carry the AbortSignal), and restore master's forms where the divergence served nothing (queue-mode sink case, single-line filter expectation, component-identity slot lookup) --- apps/web/tests/queue-actions.e2e.ts | 4 +- packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/context-provenance.ts | 13 ++++ .../tests/context-provenance.client.spec.ts | Bin 4540 -> 5161 bytes .../src/client/chat/ChatNodeSeat.tsx | 10 +-- .../src/client/chat/MessageItem.tsx | 2 +- .../src/client/input/facade.ts | 27 ++++---- .../ui-conversation/src/client/input/hub.ts | 2 +- .../src/client/input/machine.ts | 8 ++- .../ui-conversation/src/client/locales.ts | 2 + .../tests/apply-inject.client.spec.tsx | 21 +++--- .../tests/input-bar.client.spec.tsx | 62 ++++++++++++------ .../tests/input-machine.client.spec.ts | 20 +++++- .../tests/input-matrix.client.spec.tsx | 12 ++-- .../input-reference-submit.client.spec.ts | 41 ++++++++++++ .../tests/input-scenarios.client.spec.tsx | 8 +-- .../tests/skeleton.client.spec.tsx | 4 +- packages/client/ui-input-trigger/src/types.ts | 2 + .../tests/service.client.spec.ts | 12 ++++ .../tests/browser-plugin.client.spec.ts | 6 +- 20 files changed, 188 insertions(+), 70 deletions(-) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 9e03e1dbc2..a10ca7188e 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -146,9 +146,7 @@ describe('web e2e: queue row actions', () => { const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) - expect(sessionEvents.filter( - event => event.type === 'user/message' && event.data.source.kind === 'user', - )).toHaveLength(1) + expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b5433bb573..f1ab38f830 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -85,7 +85,7 @@ export { } 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' +export { contextForm, contextProvenance, sessionRecallLabels } from './sessions/context-provenance.ts' export { displayFailureMessage } from './sessions/failure-display.ts' export type { ConversationContext, ConversationContextOriginKind, diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 5912ea388a..dbd3b2dd30 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -58,6 +58,19 @@ function joined(names: string[]): string | null { return names.length > 0 ? names.join(', ') : null } +/** + * The referenced-session labels of one durable `session-reference` recall + * source, in first-seen order; empty for every other source shape, including + * a foreign or older log whose reference entries carry no readable label. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns distinct non-empty reference labels. + */ +export function sessionRecallLabels(source: unknown): string[] { + const record = asRecord(source) + if (record === null || readString(record, 'kind') !== 'session-reference') return [] + return collect(record, 'references', 'label') +} + /** * Project one durable message source onto its transcript role and producer name. * diff --git a/packages/client/runtime/tests/context-provenance.client.spec.ts b/packages/client/runtime/tests/context-provenance.client.spec.ts index e243660d601d5edbb9d2838bc5eb935102966691..b64902719c604e52f96a5f81c255583dab6a6898 100644 GIT binary patch delta 476 zcmaiwze)o^5XPGbdJqsq8xaO9ZZE9X5>SXBSXkMH6q~&{U08SH{sG}Q@(RLszJaB+ zR=$kjBcyUXi3B|eEDSTV^Zn+Vx5H=lxY*_3(R*dhHAc~+vAPEC>J z9ba+|tbo%qs51Z+ST3#BD1(Pm`Jjvppj$1l6^JF|$YF&JjYRM^Its#+U~ITm<_3t2 zzq;vTqrcMuPEWl-zq0Ob2)S!_POrVeyoFI|ltaUz{()0|{wH7diP?G`LU0kGnQ-y} z&=kdSuow4HXf`G>{;e=6+8#d4$RO+3iPh).data.source as { - kind?: string - form?: string - references?: readonly { label: string }[] - } - if (source.kind !== 'session-reference' || source.form !== 'recall' || source.references === undefined) return '' - return JSON.stringify(source.references.map(reference => reference.label)) + const labels = sessionRecallLabels((previous as ChatNode<'context'>).data.source) + return labels.length === 0 ? '' : JSON.stringify(labels) }) const referenceLabels = useMemo( () => referenceLabelsJson === '' ? [] : JSON.parse(referenceLabelsJson) as string[], diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 712bf5180f..a9c7cd6d31 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -217,7 +217,7 @@ function UserStyleBubble({ } {referenceLabels.length > 0 && (
- {t('message.referenceSummary', { labels: referenceLabels.join('、') })} + {t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })}
)} diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index f1b6c1b072..02389b7e31 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -91,6 +91,8 @@ export class SessionInputShell implements SessionInput { private noticeSeq = 0 private lastDraft = '' private imageIds: readonly DraftAttachmentId[] = [] + /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ + private imageSendInFlight = false private disposed = false /** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */ private mirrorFn: ((text: string) => void) | undefined @@ -141,16 +143,6 @@ export class SessionInputShell implements SessionInput { this.publish() } - /** - * Restore a failed attempt before any images added after its admission. - * @param ids - failed attempt image ids. - */ - restoreImages(ids: readonly DraftAttachmentId[]): void { - const current = new Set(this.imageIds) - this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds] - this.publish() - } - /** * Clear the draft as a successful-send commit: no undo unit is recorded and * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content @@ -202,13 +194,16 @@ export class SessionInputShell implements SessionInput { */ submit(mode: InputSubmitMode = 'queue'): void { if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { - if (this.snapshot.phase === 'plain') { + if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) { const imageIds = [...this.imageIds] + this.imageSendInFlight = true void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => { + this.imageSendInFlight = false if (this.disposed) return if (outcome.kind === 'success') this.commitSend(imageIds) else this.notify('error', outcome.text ?? 'prompt failed') }, (error: unknown) => { + this.imageSendInFlight = false if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error)) }) } @@ -341,13 +336,21 @@ export class SessionInputShell implements SessionInput { * a scan-derived decoration, never state. * @param text - the plain reference text to splice in (e.g. `/name `). * @param span - pick-time span snapshot (draftRev CAS). + * @param keepCompleting - re-track at the caret after the splice so an open + * token (a directory pick's trailing slash) reopens the menu. * @returns whether the text was applied. */ - insertText(text: string, span: TokenSpan): boolean { + insertText(text: string, span: TokenSpan, keepCompleting = false): boolean { const snapshot = this.core.state if (span.draftRev !== snapshot.draftRev) return false const draft = snapshot.draft this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end)) + if (keepCompleting) { + // Machine-driven draft replacement never passes through onChange, so + // re-track at the caret inside the still-open token (see space()). + const next = this.snapshot + this.deps.inputTriggers?.()?.track(next.draft, span.start + text.length, { tier: guardOf(next.phase) }, next.draftRev) + } return true } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 80c0fa9800..1fdd271503 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -91,7 +91,7 @@ export class InputHub implements SessionInputResolver { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined), actx.on('slash/input-insert-text', req => - shell.insertText(req.text, req.span) ? true : undefined), + shell.insertText(req.text, req.span, req.continue === true) ? true : undefined), ] return () => { for (const off of offs) off() diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index fd883160a1..1b8c71419b 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -523,7 +523,13 @@ export class InputMachine { this.phase = 'plain' this.claim = undefined this.occurrences = [] - this.adopt('') + // Text appended after the sent snapshot during the Host round-trip + // survives the commit; edits interleaved with committed content cannot + // be separated from it, so only a pure suffix is retained. + const snapshot = flight.attempt.draftSnapshot + this.adopt(this.draft !== snapshot && this.draft.startsWith(snapshot) + ? this.draft.slice(snapshot.length) + : '') // Committed content is gone for good: undo must not resurrect a sent draft. this.log = [] this.redoStack = [] diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 485bd67f63..976edf004c 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -95,6 +95,7 @@ export const zh = { 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', 'message.referenceSummary': '引用会话 · {labels}', + 'message.referenceSeparator': '、', 'message.context.instructions.loaded': '已载入', 'message.context.instructions.added': '已新增', 'message.context.instructions.updated': '已更新', @@ -265,6 +266,7 @@ export const en = { 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', 'message.referenceSummary': 'Referenced session · {labels}', + 'message.referenceSeparator': ', ', 'message.context.instructions.loaded': 'loaded', 'message.context.instructions.added': 'added', 'message.context.instructions.updated': 'updated', diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 9cec01789a..7b6dc4e51d 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -2,7 +2,7 @@ // apply inject factories exercised end to end against the terminal thin // API: the strict session API (views triple, draft mirror), the // provide-channel input face (machine-sink submit choreography incl. -// optimistic clear + failure restore), the resident API (selectWorkspace +// transactional clear + failure retention), the resident API (selectWorkspace // draft carrying), the composer-bar stop face, openDetails = select action + // layout orchestration, and the closeDetails details API. Complements // chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store @@ -150,7 +150,7 @@ describe('conversation slot inject API', () => { await b.runtime.dispose() }) - it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => { + it('the provide-channel input face submits through the machine sink: trim, transactional clear, failure retains the draft', async () => { const b = await bench() const { injected } = b.conversationApi(ROOT) const { state, actions } = b.inputApi(ROOT) @@ -159,20 +159,23 @@ describe('conversation slot inject API', () => { actions.submit() expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(state.getSnapshot().draft).toBe(' ') - // Success: cleared and stays cleared. + // Success: the draft clears only after the sink settles. actions.setDraft('hello') actions.submit() - expect(state.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). + await vi.waitFor(() => { + expect(state.getSnapshot().draft).toBe('') + }) + expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal)) + // Failure: the draft is retained through the round-trip. b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.setDraft('retry me') actions.submit() await vi.waitFor(() => { - expect(state.getSnapshot().draft).toBe('retry me') + expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) }) - // Failure landing after new typing: no clobber (restore fills empty only). + await new Promise(r => setTimeout(r, 0)) + expect(state.getSnapshot().draft).toBe('retry me') + // Failure landing after new typing: no clobber (the interleaved edit wins). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.submit() actions.setDraft('typed during flight') diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index f7d5e02a7f..c67e901532 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -13,6 +13,7 @@ import { 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' +import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { SessionInputShell } from '../src/client/input/facade.ts' import type { ComposerAttachment } from '../src/client/contract/slots.ts' import type { DraftAttachmentId } from '../src/client/input/contract.ts' @@ -101,7 +102,12 @@ function row(id: string): ConversationSnapshot['queue'][number] { /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ function bench(over?: BenchOptions) { - const sink = vi.fn() + const sink = vi.fn<( + text: string, + imageIds: readonly DraftAttachmentId[], + mode: 'queue' | 'steer', + signal: AbortSignal, + ) => Promise>(() => Promise.resolve({ kind: 'success' })) const lex = over?.lexicon const session = createSnapshotStore(snapshotOf({ running: over?.running ?? false, @@ -365,15 +371,28 @@ describe('image draft rail', () => { expect(view.queryByRole('status')).toBeNull() }) - it('sends an image-only draft and removes its thumbnail', () => { + it('sends an image-only draft and removes its thumbnail', async () => { const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) - 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 extra = new File([Uint8Array.of(2)], 'extra.png', { type: 'image/png' }) + const attachments = [ + { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }, + { kind: 'image' as const, id: 'draft-2' as DraftAttachmentId, file: extra, previewUrl: 'blob:draft-2' }, + ] + const { view, textarea, sink, removeImage } = bench({ attachments }) expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false) + fireEvent.click(view.getByRole('button', { name: '移除图片 extra.png' })) + expect(removeImage).toHaveBeenCalledWith('draft-2') + let settle!: (outcome: SubmitOutcome) => void + sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue') - fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) - expect(removeImage).toHaveBeenCalledWith('draft-1') + expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal)) + // The sent thumbnail stays on the rail through the round-trip and leaves + // only after the success settles. + expect(view.getByRole('button', { name: '移除图片 pixel.png' })).toBeTruthy() + settle({ kind: 'success' }) + await vi.waitFor(() => { + expect(view.queryByRole('button', { name: '移除图片 pixel.png' })).toBeNull() + }) }) it('opens the original image on a single click and closes it with Escape', () => { @@ -476,8 +495,11 @@ describe('Enter semantics', () => { it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('hello', [], 'queue') + expect(sink).toHaveBeenCalledWith('hello', [], 'queue', expect.any(AbortSignal)) + // The submitting-phase lock, not draft emptiness, suppresses the repeat: + // the draft is still uncleared while the sink round-trip is in flight. fireEvent.keyDown(textarea, { key: 'Enter', repeat: true }) + fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledTimes(1) const empty = bench({ draft: ' ' }) fireEvent.keyDown(empty.textarea, { key: 'Enter' }) @@ -501,15 +523,15 @@ describe('Enter semantics', () => { it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => { const idle = bench({ draft: 'hello' }) fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) - expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue') + expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue', expect.any(AbortSignal)) const busyCtrl = bench({ running: true, draft: 'steer with ctrl' }) fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true }) - expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer') + expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer', expect.any(AbortSignal)) const busyMeta = bench({ running: true, draft: 'steer with cmd' }) fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true }) - expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer') + expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer', expect.any(AbortSignal)) }) it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => { @@ -574,7 +596,7 @@ describe('Enter semantics', () => { const steerQueue = vi.fn() const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue }) fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) - expect(sink).toHaveBeenCalledWith('插话', [], 'steer') + expect(sink).toHaveBeenCalledWith('插话', [], 'steer', expect.any(AbortSignal)) expect(steerQueue).not.toHaveBeenCalled() }) @@ -622,7 +644,7 @@ describe('running and lock semantics', () => { expect(textarea.disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队消息2' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue') + expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal)) expect(button.getAttribute('aria-label')).toBe('停止生成') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) @@ -631,17 +653,17 @@ describe('running and lock semantics', () => { it('running plain Enter follows the busy-state Steer preference', () => { const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer') + expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer', expect.any(AbortSignal)) }) it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => { const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' }) fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) - expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue') + expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue', expect.any(AbortSignal)) const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' }) fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) - expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue') + expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue', expect.any(AbortSignal)) }) it('running continuable subagent keeps Send beside an independent Stop', () => { @@ -661,7 +683,7 @@ describe('running and lock semantics', () => { expect(interruptButton).not.toBeNull() expect(textarea.disabled).toBe(false) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue') + expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal)) fireEvent.click(interruptButton!) expect(stop).toHaveBeenCalledTimes(1) }) @@ -718,11 +740,11 @@ describe('running and lock semantics', () => { } const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent }) fireEvent.keyDown(plain.textarea, { key: 'Enter' }) - expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue') + expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue', expect.any(AbortSignal)) const accelerated = bench({ running: true, draft: 'accelerated', subagent }) fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true }) - expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue') + expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue', expect.any(AbortSignal)) }) it('disabled (session removed) locks the textarea and chrome', () => { @@ -735,7 +757,7 @@ describe('running and lock semantics', () => { it('idle primary sends and disables on empty draft', () => { const { button, sink } = bench({ draft: 'go' }) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('go', [], 'queue') + expect(sink).toHaveBeenCalledWith('go', [], 'queue', expect.any(AbortSignal)) const empty = bench() expect(empty.button.disabled).toBe(true) }) diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index 4bb4a14e7e..192d4c7efa 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -72,8 +72,8 @@ describe('input-machine: plain × enter', () => { it('non-command text falls to the default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - const effect = effectAt(m.dispatch({ type: 'enter', mode: 'steer' }), 0, 'default-sink') - expect(effect).toMatchObject({ draft: 'hello world', mode: 'steer' }) + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + expect(effect).toMatchObject({ draft: 'hello world', mode: 'queue' }) expect(effect.attempt.draftSnapshot).toBe('hello world') expect(m.state.phase).toBe('submitting') }) @@ -488,6 +488,22 @@ describe('input-machine: undo / redo', () => { expect(m.dispatch({ type: 'undo' })).toEqual([]) expect(m.state.draft).toBe('') }) + + it('keeps a suffix typed during the round-trip and drops interleaved edits with the commit', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: 'hello' }) + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + m.dispatch({ type: 'draft-changed', draft: 'hello world' }) + m.dispatch({ type: 'submit-settled', attempt: effect.attempt, ok: true }) + expect(m.state.draft).toBe(' world') + + const n = new InputMachine() + n.dispatch({ type: 'draft-changed', draft: 'hello' }) + const second = effectAt(n.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + n.dispatch({ type: 'draft-changed', draft: 'hXello' }) + n.dispatch({ type: 'submit-settled', attempt: second.attempt, ok: true }) + expect(n.state.draft).toBe('') + }) }) describe('input-machine: paste plane', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 9518318557..eff49c2ca9 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -69,7 +69,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled } function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise }) { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink }) const wiring = shell const view = mountBar(shell, over) @@ -90,13 +90,15 @@ function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: s } describe('matrix row: plain', () => { - it('enter falls to the default sink; no claim on the currency; edits free', () => { + it('enter falls to the default sink; no claim on the currency; edits free', async () => { const { textarea, shell, sink } = bench() fireEvent.change(textarea, { target: { value: '普通消息' } }) expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue') - expect(shell.snapshot.phase).toBe('plain') + expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.phase).toBe('submitting') + await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + expect(shell.snapshot.claim).toBeUndefined() }) }) @@ -194,7 +196,7 @@ describe('matrix row: locked (session disabled)', () => { expect((textarea).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队', [], 'queue') + expect(sink).toHaveBeenCalledWith('排队', [], 'queue', expect.any(AbortSignal)) }) }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index bac940eb18..d41253a299 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -120,3 +120,44 @@ describe('reference submission', () => { expect(shell.snapshot.draft).toBe('send this') }) }) + +describe('submit transaction hardening', () => { + it('sends one image-only prompt per settlement, ignoring Enter during the round-trip', async () => { + let settle!: (outcome: SubmitOutcome) => void + const sink = vi.fn(() => new Promise((resolve) => { settle = resolve })) + const shell = new SessionInputShell({ + actx: {} as ClientContext, + defaultSink: sink, + }) + expect(shell.addImages(['img-1' as DraftAttachmentId])).toBe(true) + shell.submit('queue') + shell.submit('queue') + expect(sink).toHaveBeenCalledTimes(1) + settle({ kind: 'success' }) + await vi.waitFor(() => { + expect(shell.snapshot.imageIds).toEqual([]) + }) + + expect(shell.addImages(['img-2' as DraftAttachmentId])).toBe(true) + shell.submit('queue') + expect(sink).toHaveBeenCalledTimes(2) + }) + + it('re-tracks at the caret when a continuing insert-text splice lands (directory descent)', () => { + const track = vi.fn() + const shell = new SessionInputShell({ + actx: {} as ClientContext, + inputTriggers: () => ({ track } as unknown as InputTriggerController), + defaultSink: vi.fn(), + }) + shell.setDraft('@sr') + const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true) + expect(applied).toBe(true) + expect(shell.snapshot.draft).toBe('@src/') + expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev) + + track.mockClear() + shell.insertText(' plain ', { start: 0, end: 0, draftRev: shell.snapshot.draftRev }) + expect(track).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 2a1c114226..0daadabea4 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -106,7 +106,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo register?.(inputTriggers) const actx = sessions.scope(sessionId)! const controller = inputTriggers.sessionOf(actx) - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink }) // The hub's listener wiring, verbatim. actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined) @@ -243,7 +243,7 @@ describe('scenario D: execute-kind /compact', () => { act(() => { b2.shell.setDraft('/compact 现在') }) fireEvent.keyDown(b2.textarea, { key: 'Enter' }) // execute with trailing → matchEnter answers undefined → default sink. - await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue') }) + await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue', expect.any(AbortSignal)) }) expect(b2.executed).toHaveLength(0) }) }) @@ -297,8 +297,8 @@ describe('scenario I: unknown /xyz + enter', () => { const b = await bench() act(() => { b.shell.setDraft('/xyz 干点啥') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue') }) - expect(b.shell.snapshot.phase).toBe('plain') + await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue', expect.any(AbortSignal)) }) + await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') }) expect(b.execute).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index acf618fb2b..8f4f938253 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -31,7 +31,7 @@ import type { ViewTab } from '../src/client/contract/views.ts' /** Machine-backed wiring over a sink spy. */ function fakeWiring() { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink }) return { wiring: shell, sink, shell } } @@ -309,7 +309,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue') + expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue', expect.any(AbortSignal)) expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) expect(b.view.queryByText('Root')).toBeNull() }) diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 1e19957191..ba1ba81548 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -224,6 +224,8 @@ export interface InsertTextRequest { /** Literal replacement for the trigger token span (e.g. `/name `). */ readonly text: string readonly span: TokenSpan + /** Keep completion open after the splice (directory descent): the input re-tracks at the caret. */ + readonly continue?: boolean } declare module '@deepseek-ai/cordis' { diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 3cbc472c4f..e24e199d72 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -498,6 +498,18 @@ describe('pick / scoped input events', () => { expect(controller.menu.getSnapshot().open).toBe(false) }) + it('forwards a continuing text outcome so a directory pick keeps completion open', async () => { + const { controller, actx } = pickBench(() => ({ text: '@src/', continue: true })) + const texts: Array<{ text: string; continue?: boolean }> = [] + actx.on('slash/input-insert-text', (req) => { + texts.push(req) + return true + }) + await tick() + controller.pick('command', 0) + expect(texts).toEqual([{ text: '@src/', continue: true, span: { start: 0, end: 2, draftRev: 3 } }]) + }) + it('a text outcome the input declines answers false on the space path', async () => { const src: InputTriggerSource = { trigger: '/', diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 0f39e47f5a..0758235aec 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -8,7 +8,9 @@ import { } from '@deepseek-ai/dsh-client-runtime/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import type { SubagentCatalogInjected } from '../src/client/SubagentCatalogAction.tsx' +import { + SubagentCatalogAction, type SubagentCatalogInjected, +} from '../src/client/SubagentCatalogAction.tsx' import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from '../src/client/SubagentReadOnlyComposer.tsx' @@ -93,7 +95,7 @@ describe('apply', () => { it('registers catalog actions and selects read-only subagent composers from session facts', async () => { const { ctx, face } = await fullBench(FAMILY) const catalogEntry = ctx.slots.entries('conversation.session.header.actions') - .find(entry => entry.options.id === 'subagent-catalog')! + .find(entry => entry.component === SubagentCatalogAction)! const actions = (catalogEntry.inject as unknown as (id: SessionId) => SubagentCatalogInjected)(sid('parent')) const address: SubagentAddress = { parentSessionId: sid('parent'), From 862132faca0d319c126513adc4d41381fa48930c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 18:47:11 +0800 Subject: [PATCH 044/180] test(web): cover the ui-reference node half --- .../client/ui-reference/tests/browser-plugin.client.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 11357fcc23..7c18e861e6 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -13,6 +13,7 @@ import type { import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' const sid = (value: string): SessionId => value as SessionId const session: ClientSessionContext = { sessionId: sid('target') } @@ -111,6 +112,10 @@ describe('apply', () => { expect(registered).toBeUndefined() await fiber.dispose() }) + + it('the node half applies without host-side behavior', () => { + expect(() => { nodeApply() }).not.toThrow() + }) }) describe('candidates', () => { From 29de639e998cbd0ebfd0738bfcf051631ef83af3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 21:13:33 +0800 Subject: [PATCH 045/180] =?UTF-8?q?fix(web):=20=E6=8C=89=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E6=84=8F=E8=A7=81=E8=B0=83=E6=95=B4=E5=BC=95=E7=94=A8=E8=81=8C?= =?UTF-8?q?=E8=B4=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-07-21-cross-session-references.i18n.yaml | 4 +- .../2026-07-21-cross-session-references.md | 16 +- .../2026-07-21-cross-session-references.zh.md | 16 +- ...-web-file-and-session-references.i18n.yaml | 4 +- ...6-07-27-web-file-and-session-references.md | 18 +- ...7-27-web-file-and-session-references.zh.md | 18 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 33 +- docs/module-graph.zh.md | 33 +- docs/subsystems/session-reference.i18n.yaml | 4 +- docs/subsystems/session-reference.md | 4 +- docs/subsystems/session-reference.zh.md | 4 +- .../client/connection/src/client/fixture.ts | 2 +- .../src/client/chat/ChatNodeSeat.tsx | 17 +- .../src/client/chat/MessageItem.tsx | 4 +- .../src/client/contract/slots.ts | 2 - .../src/client/conversation-nodes/message.ts | 25 +- .../tests/chat-branch-tails.client.spec.tsx | 8 +- ...nversation-node-definitions.client.spec.ts | 25 ++ packages/client/ui-reference/README.i18n.yaml | 4 +- packages/client/ui-reference/README.md | 4 +- packages/client/ui-reference/README.zh.md | 4 +- .../client/ui-reference/src/client/index.ts | 23 +- .../session-reference/README.i18n.yaml | 4 +- packages/context/session-reference/README.md | 6 +- .../context/session-reference/README.zh.md | 6 +- .../context/session-reference/src/index.ts | 50 ++- .../tests/session-reference.spec.ts | 75 +++- .../src/client/slot-catalog.ts | 2 +- .../extensions/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/package.json | 4 +- packages/host/apiproxy/src/api-proxy.ts | 167 +------ packages/host/apiproxy/src/api/rpc.schema.ts | 3 - packages/host/apiproxy/src/api/rpc.ts | 3 - packages/host/apiproxy/src/api/sessions.ts | 2 +- packages/host/apiproxy/src/fetch/handler.ts | 5 +- .../tests/api-proxy-references.spec.ts | 412 ------------------ .../host/apiproxy/tests/rpc-schemas.spec.ts | 5 - packages/host/apiproxy/tsconfig.json | 3 - pnpm-lock.yaml | 3 - 46 files changed, 325 insertions(+), 729 deletions(-) delete mode 100644 packages/host/apiproxy/tests/api-proxy-references.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index 0cc877effb..40f4c554ef 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages 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-cross-session-references.md -2026-07-21-cross-session-references.md: d9aaaca442ba939b4221351e0df3601294674587 -2026-07-21-cross-session-references.zh.md: 17c3f91225f5f5ae2c2b1a639cc9d3c1794a490b +2026-07-21-cross-session-references.md: 774a948345e3d45adbba47ef6a7edd3e6f0740b2 +2026-07-21-cross-session-references.zh.md: fd864203d17954167646edf3b1a62946f8fa3f23 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index d9aaaca442..774a948345 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -10,7 +10,7 @@ Web users need to bring relevant work from another conversation into one new mes ## Decision -`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferenceResolver`. Hosts normalize their protocol into `SessionReferenceInput[]` and call `prepare()` before delivery. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log. +`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferenceResolver`. Its outer `agent/pre-step` listener parses canonical mentions in accepted direct user messages and calls `prepare()` without adding reference behavior to a host gateway. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log. `dsh-session:` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. Web receives that URI inside the Host-produced `@[label](uri)` mention and keeps it behind an atomic session chip; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text. @@ -18,7 +18,7 @@ The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live ## Snapshot and projection -Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `followup()` or `steer()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session. +Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partial context: any read, cancellation, validation, or budget error ends the turn before the accepted messages enter model-visible history. Cancellation races in-flight discovery and exact reads, so the listener settles promptly even when a persistence backend cannot interrupt its pending operation. A queued message captures each source when it reaches `agent/pre-step`; later source messages, compaction, deletion, or persistence replacement cannot change the context recorded in the target session. Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compaction`. That marker is part of the compaction capability contract rather than a backend package name. Reference snapshots remain separate sourced `user/message` events, so projection excludes them as injected context and never recursively propagates an earlier snapshot. Projection also excludes shadowed pre-compaction nodes, tools and results, reasoning, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery. @@ -26,15 +26,15 @@ One aggregated context is serialized as JSON beneath a fixed untrusted-backgroun ## Message ownership -Web owns the snapshot/direct-message transaction without extending the generic inbox record. Before delivery it installs a one-shot outer `agent/pre-step` listener keyed by the prepared direct message id. When a pre-step enters with a claimed batch containing that id, the listener inserts the frozen snapshot immediately before that direct message; discarding the direct message removes the listener without writing the snapshot. Moving the queued message to steering preserves its identity and listener, so both placements reach the same pre-step boundary in the same order. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this generic delivery boundary. +The service's outer `agent/pre-step` listener calls downstream listeners first and processes only an `enter` decision. It parses each accepted direct user message, preserves that message's id while replacing canonical mentions with readable labels, and inserts the frozen snapshot immediately before that message. Queue edits and queue-to-steer relocation need no reference-specific state because the final claimed messages are the input to preparation. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this context ordering. -Reference preparation is not a new steering protocol and does not create a turn by itself. Ordinary queued delivery and queue-to-steer relocation share the message-id-scoped pre-step listener, retaining snapshot order without a second context path. +Reference preparation is not a new delivery protocol and does not create a turn by itself. A preparation failure terminates the already accepted turn through the agent loop's existing plugin-failure path. ## Host adapters The unified Web `@` source combines session candidates with Host-backed file discovery. Session candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the request's cancellation signal, and session id, cwd, and mention labels escape external control characters while the canonical URI retains the original id. -Web exposes discovery through the `sessionReferenceResolver/candidates` Remote method and preparation through `session.prompt`, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. The composer retains text and chips until preparation and enqueue succeed, restoring them unchanged after failure; replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON. +Web exposes file and session discovery through generated Remote methods on their owning services, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. Ordinary `session.prompt` delivery carries that mention without a reference-specific API Proxy route. Replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. @@ -47,15 +47,15 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b - **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only. - **Put mention syntax in agent delivery methods** — rejected because it would make the core protocol parse one host's presentation syntax and prevent typed non-text hosts from sharing the semantic layer. - **Implement references separately in each host** — rejected because projection, security warning, retention, and persistence would drift across hosts. -- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. A domain-specific admission wrapper and the existing next-step inbox preserve the required pairing without enlarging every direct prompt. +- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. The domain listener can prepare the final claimed message without enlarging every direct prompt. - **Bake the prefix host-side before `followup()`** — rejected because `agent/pre-step` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets Web hide background bytes from the direct user bubble. - **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history. - **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity. -- **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state. +- **Reread the source after the model step enters** — rejected because target replay would depend on external mutable state instead of the logged snapshot. ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, message-id-scoped pre-step insertion, discard cleanup, queue-to-steer preservation, title isolation, missing capability, Web wire preparation, and failure-preserving Web submission. A keyless Web snapshot pins the assembled reference selection path. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, pre-step parsing and insertion, downstream rejection, node-owned replay association, title isolation, and the generated Remote discovery faces. A keyless Web snapshot pins the assembled reference selection path. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index 17c3f91225..fd864203d1 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -10,7 +10,7 @@ Web 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 决策 -`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferenceResolver` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,并在交付前调用 `prepare()`。该服务返回分离的可读内容和一份可选的、带标识且冻结的 `UserMessage` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。 +`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferenceResolver` 上的单一上下文消费服务。它的外层 `agent/pre-step` 监听器会解析已接受直接用户消息中的规范 mention,并调用 `prepare()`,宿主网关无需添加引用行为。该服务返回分离的可读内容和一份可选的、带标识且冻结的 `UserMessage` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。 `dsh-session:` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。Web 接收由 Host 生成、包含该 URI 的 `@[label](uri)` 提及标记,并把它封装为原子 session chip;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。 @@ -18,7 +18,7 @@ Web 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 快照与投影 -准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `followup()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。 +准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分上下文:任何读取、取消、校验或预算错误都会在已接受消息进入面向模型的历史之前结束该轮次。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,监听器也能及时结束等待。queued 消息到达 `agent/pre-step` 时会捕获每个源;此后源会话新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中记录的上下文。 投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compaction` 导出的规范来源标记的检查点用户消息。该标记属于压缩能力约定的一部分,而非某个后端包名称。引用快照始终是独立且带来源的 `user/message` 事件,因此投影会把它们作为注入上下文排除,绝不递归传播早先的快照。投影还会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 @@ -26,15 +26,15 @@ Web 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 消息所有权 -Web 负责快照/直接消息事务,不扩展通用收件箱记录。交付前,它会安装一个以已准备直接消息 id 为键的一次性外层 `agent/pre-step` 监听器。当 pre-step 以包含该 id 的已领取批次进入时,监听器会把冻结快照紧邻插入到该直接消息之前;丢弃直接消息会移除监听器且不写入快照。把 queued 消息移到 steering 时会保留其身份和监听器,因此两种放置方式都以相同顺序抵达同一 pre-step 边界。这一通用交付边界由[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定。 +该服务的外层 `agent/pre-step` 监听器会先调用下游监听器,并且只处理 `enter` 决策。它会解析每条已接受的直接用户消息,在把规范 mention 替换为可读标签时保留消息 id,并把冻结快照插入到该消息紧前。最终领取的消息是准备过程的输入,因此队列编辑和从 queue 移动到 steer 不需要引用专用状态。[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定了这一上下文顺序。 -引用准备过程不是新的 steering 协议,本身也不会创建轮次。普通 queued 交付与 queue-to-steer 移动共用以消息 id 为范围的 pre-step 监听器,无需第二条上下文路径即可保持快照顺序。 +引用准备过程不是新的投递协议,本身也不会创建轮次。准备失败会通过 agent loop 的现有插件失败路径终止已经接受的轮次。 ## 宿主适配器 统一的 Web `@` source 把会话候选与 Host 支持的文件发现组合在一起。会话候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。查询遵循请求的取消信号;session id、cwd 和提及标签中的外部控制字符会被转义,但规范 URI 仍保留原始 id。 -Web 通过 `sessionReferenceResolver/candidates` Remote 方法暴露发现流程、通过 `session.prompt` 暴露准备流程,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范提及标记支撑的原子 chip。composer 会保留文本和 chip,直到准备与入队均成功;失败后会原样恢复。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 +Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范 mention 支撑的原子 chip。普通 `session.prompt` 投递会携带该 mention,无需引用专用 API Proxy 路由。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 @@ -47,15 +47,15 @@ Web 通过 `sessionReferenceResolver/candidates` Remote 方法暴露发现流程 - **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。 - **把提及标记语法放入 agent 投递方法**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。 - **在每个宿主中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。 -- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step inbox 可以保持所需配对,而无需扩大每条直接提示词。 +- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域监听器可以准备最终领取的消息,无需扩大每条直接提示词。 - **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/pre-step` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 Web 从直接用户气泡中隐藏背景字节。 - **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。 - **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。 -- **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。 +- **模型步骤进入后重新读取源会话**:不予采纳,因为目标回放会依赖可变的外部状态,而不是已记录的快照。 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结消息所有权、按消息 id 限定的 pre-step 插入、丢弃清理、queue-to-steer 关联保留、标题隔离、能力缺失、Web 线路准备,以及失败时保留输入的 Web 提交。一个无密钥 Web 快照会固定组装后的引用选择路径。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时的取消、逐源独立字节保留、冻结消息所有权、pre-step 解析和插入、下游拒绝、节点负责的回放关联、标题隔离,以及生成的 Remote 发现接口。一个无密钥 Web 快照会固定组装后的引用选择路径。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml index fcd057ebd7..46f6c117eb 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages 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-web-file-and-session-references.md -2026-07-27-web-file-and-session-references.md: 5032525d6362b1afa0da387c2dd3d5f2d961139f -2026-07-27-web-file-and-session-references.zh.md: fce6ec0d35b7712d2e966932144b468f88b67862 +2026-07-27-web-file-and-session-references.md: ad8e5c53832a567bd38d1d1e560122cb8b630daa +2026-07-27-web-file-and-session-references.zh.md: acb016866efc42ef3ea9f661cb10ee1459cf1a6b diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md index 5032525d63..ad8e5c5383 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -6,7 +6,7 @@ English | [中文](2026-07-27-web-file-and-session-references.zh.md) ## Problem -The Web composer had a reusable slash/reference trigger pipeline, but its `@` source was inert subagent-label text. Web needed Host-backed workspace-path discovery and structured cross-session snapshots without scanning the Host filesystem in the browser, binding session identity to a display label, or clearing a draft before Host-side snapshot preparation succeeded. +The Web composer had a reusable slash/reference trigger pipeline, but its `@` source was inert subagent-label text. Web needed Host-backed workspace-path discovery and structured cross-session snapshots without scanning the Host filesystem in the browser or binding session identity to a display label. ## Decision @@ -14,19 +14,19 @@ Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-c The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the generated Remote namespaces and shared grammar. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash. -A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. `session.prompt` parses those mentions and calls `ctx.sessionReferenceResolver.prepare()` before delivery. Delivery binds the prepared context to the exact message id through a one-shot outer `agent/pre-step` listener that inserts the frozen snapshot immediately before that message on an enter decision; ordinary discard or agent disposal releases the listener, and queue-to-steer relocation preserves the pairing. Invalid mentions, cancellation, missing capability, source-read failure, and budget failure deliver nothing. +A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. Ordinary `session.prompt` delivery carries that mention unchanged. The session-reference service parses accepted direct user messages at `agent/pre-step`, captures every source, replaces the canonical mention with readable text while preserving the direct message id, and inserts the frozen snapshot immediately before that message. The API Proxy contains no reference-specific route, dependency, or error code. -The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or Remote failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders adjacent metadata-confirmed session references as separate reference chips and preserves that projection when following text is adjacent without whitespace, plus a compact session-source summary instead of the snapshot JSON baked into model content. +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The concrete user and steering chat-node definition associates labels from an immediately preceding session-reference context, so the renderer receives the association from its own node data and shows a compact source summary instead of snapshot JSON. ## Reference transaction ```text type @ → parallel file/session Remote calls → pick path text or canonical session chip - → serialize draft → Host parses and prepares all sessions → enqueue once - ↘ any pre-enqueue failure: retain the unchanged editable draft + → serialize draft → ordinary session.prompt enqueue + → agent/pre-step parses mentions → capture sources → context + readable prompt ``` -File lookup is advisory and cancellable; selection itself performs no read. Session preparation is authoritative and all-or-nothing because the source snapshot must be fixed before the target inbox accepts the message. +File lookup is advisory and cancellable; selection itself performs no read. Session preparation is all-or-nothing for one accepted model step. A queued message captures each source when the message is claimed, so queue edits and queue-to-steer relocation use the same path without gateway coordination. ## Alternatives considered @@ -38,12 +38,12 @@ File lookup is advisory and cancellable; selection itself performs no read. Sess **Represent sessions as plain `@label` text.** Rejected because labels are neither stable nor unique and cannot identify the source snapshot. Canonical Host-produced mentions preserve opaque session identity while keeping a readable display. -**Clear the composer before the RPC settles.** Rejected because a failed preparation would lose the only editable copy of the request and visually claim acceptance that never occurred. +**Clear the composer before prompt admission settles.** Rejected because a transport or admission failure would lose the only editable copy of the request and visually claim acceptance that never occurred. ## Verification -Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, the owning services' Remote faces, all-or-nothing prompt preparation, and draft retention across serialization and Remote failures. The keyless assembled Web snapshot renders the available reference sections, selects a file, then selects a session reference through the real client composition. +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, generated Remote type inference, pre-step preparation, downstream rejection, and chat-node-owned label association. The keyless assembled Web snapshot renders the available reference sections, selects a file, then selects a session reference through the real client composition. ## Consequences -Web now uses the shared `@file` discovery seam and structured session-reference identity, while Host services remain the authority for filesystem and session access. The new file-reference seam adds two packages whose discovery methods are unary Remote contracts on the owning services, keeps browser bundles free of Node APIs, and permits another provider to align completion with a remote filesystem. Candidate lookup failures remain quiet menu degradation; submission failures remain explicit and recoverable. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. +Web now uses the shared `@file` discovery seam and structured session-reference identity, while Host services remain the authority for filesystem and session access. File and session discovery are unary Remote contracts on the owning services, so generated client types replace handwritten RPC interfaces and browser bundles remain free of Node APIs. Candidate lookup failures remain quiet menu degradation. Reference preparation failures occur after prompt acceptance and end the agent turn. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md index fce6ec0d35..acb016866e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 `@` source 只是不会产生实际作用的 subagent 标签文本。Web 需要由宿主提供工作区路径发现和结构化跨会话快照,同时避免在浏览器中扫描宿主文件系统、把会话身份绑定到显示标签,或者在宿主侧快照准备成功前清除草稿。 +Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 `@` source 只是不会产生实际作用的 subagent 标签文本。Web 需要由宿主提供工作区路径发现和结构化跨会话快照,同时避免在浏览器中扫描宿主文件系统或把会话身份绑定到显示标签。 ## 决策 @@ -14,19 +14,19 @@ Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费生成的 Remote 命名空间与共享语法。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。 -选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` 提及标记。`session.prompt` 会解析这些提及标记,并在递送前调用 `ctx.sessionReferenceResolver.prepare()`。递送通过一次性的外层 `agent/pre-step` 监听器把准备后的上下文绑定到精确消息 id:进入决策时把冻结快照插入到该消息紧前,普通丢弃或 agent(智能体)销毁会释放监听器,队列转 steering 的迁移保持这一配对。无效提及标记、取消、功能缺失、读取源会话失败和预算失败都不会递送消息。 +选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` mention。普通 `session.prompt` 投递会原样携带该 mention。session-reference 服务会在 `agent/pre-step` 解析已接受的直接用户消息,捕获每个源,在保留直接消息 id 的同时把规范 mention 替换为可读文本,并把冻结快照插入到该消息紧前。API Proxy 不包含引用专用路由、依赖或错误码。 -输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 Remote 调用失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把相邻且经元数据确认的会话引用分别渲染为独立的引用 chip,即使后续文本无空白紧邻,也会保持该投影;同时显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。具体的 user 和 steering chat-node 定义会关联紧邻前一条 session-reference 上下文中的标签,因此渲染器会从自身节点数据接收关联信息,并显示精简的来源摘要,而不是快照 JSON。 ## 引用事务 ```text type @ → parallel file/session Remote calls → pick path text or canonical session chip - → serialize draft → Host parses and prepares all sessions → enqueue once - ↘ any pre-enqueue failure: retain the unchanged editable draft + → serialize draft → ordinary session.prompt enqueue + → agent/pre-step parses mentions → capture sources → context + readable prompt ``` -文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备具有权威性,并且必须全有或全无,因为目标收件箱接受消息前必须固定源快照。 +文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备针对一个已接受的模型步骤保持全有或全无。queued 消息被领取时会捕获每个源,因此队列编辑和从 queue 移动到 steer 使用同一路径,无需网关协调。 ## 备选方案 @@ -38,12 +38,12 @@ type @ → parallel file/session Remote calls → pick path text or canonical se **用普通 `@label` 文本表示会话。** 不予采纳,因为标签既不稳定也不唯一,无法标识源快照。宿主生成的规范提及标记既能保留不透明会话身份,也能保持显示内容易读。 -**RPC 完成前清空输入框。** 不予采纳,因为准备失败会丢失请求唯一可编辑的副本,并在视觉上错误表示一个从未成功的接受操作。 +**提示词准入结算前清空输入框。** 不予采纳,因为传输或准入失败会丢失请求唯一可编辑的副本,并在视觉上错误表示一个从未成功的接受操作。 ## 验证 -包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、所属服务的 Remote 调用面、全有或全无的提示词准备,以及在序列化和 Remote 调用失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、生成的 Remote 类型推断、pre-step 准备、下游拒绝,以及 chat node 自有的标签关联。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 ## 后果 -Web 现在使用共享的 `@file` 发现 seam 和结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。新的文件引用 seam 增加了两个包,其发现方法是所属服务上的一元 Remote 契约;浏览器 bundle 中不包含 Node API,并允许其他提供方让补全与远程文件系统对齐。候选查询失败仍会让菜单静默降级;提交失败仍会显式报告且可恢复。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 +Web 现在使用共享的 `@file` 发现 seam 和结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。文件和会话发现都是所属服务上的一元 Remote 契约,因此生成的客户端类型会替代手写 RPC 接口,浏览器 bundle 中也不包含 Node API。候选查询失败仍会让菜单静默降级。引用准备失败发生在提示词已接受之后,并会结束 agent 轮次。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 4a61e2d00c..e271745350 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: f088251b46c66588bd932141df448b7312ac5c52 -event-producer-consumer.zh.md: ef2b5a45465f1a6ed985cc04496573b300fec606 +event-producer-consumer.md: 34b7d8d7cbee89753516485e35a5b4611b1b9077 +event-producer-consumer.zh.md: 3216c76a7a33c473e4ed67cfbc2c4ba7fb442c9f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f088251b46..34b7d8d7cb 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,12 +10,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index ef2b5a4546..3216c76a7a 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -12,12 +12,12 @@ | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 826a05243c..00dcfcd1a6 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: 2eb7c748ee0bcf6eb63d200841e35f2606958a24 -module-graph.zh.md: 35a5615914711da1f52e2ecfb938c2e134f6afcb +module-graph.md: ce9cd0f0e7bdf5be6ada822cb6ba4e975c6a2f35 +module-graph.zh.md: 631c2c535e987d2699c0cbd453ae7d8626599c8a diff --git a/docs/module-graph.md b/docs/module-graph.md index 2eb7c748ee..ce9cd0f0e7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -140,6 +140,7 @@ flowchart TD pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] + pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] @@ -171,6 +172,8 @@ flowchart TD end subgraph group_context["packages/context"] pkg_agent_instructions["agent-instructions"] + pkg_file_reference["file-reference"] + pkg_file_reference_local["file-reference-local"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -513,6 +516,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants + pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -834,6 +840,11 @@ flowchart TD pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session pkg_agent_instructions --> pkg_tools + pkg_file_reference_local --> pkg_agent + pkg_file_reference_local --> pkg_file_reference + pkg_file_reference_local --> pkg_invariants + pkg_file_reference_local --> pkg_system_prompt + pkg_file_reference_local --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction pkg_session_reference --> pkg_invariants @@ -841,6 +852,7 @@ flowchart TD pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_typert_protocol pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -1004,6 +1016,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants @@ -1011,6 +1024,7 @@ flowchart TD pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_typert_registry pkg_web_app --> pkg_invariants @@ -1261,6 +1275,15 @@ flowchart TD 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_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_client_locale + pkg_client_ui_reference --> pkg_client_runtime + pkg_client_ui_reference --> pkg_client_ui_input_trigger + pkg_client_ui_reference --> pkg_client_ui_slots + pkg_client_ui_reference --> pkg_file_reference + pkg_client_ui_reference --> pkg_invariants + pkg_client_ui_reference --> pkg_session_reference + pkg_client_ui_reference --> pkg_typert_protocol pkg_client_ui_settings_general --> pkg_api_remotes pkg_client_ui_settings_general --> pkg_client_connection pkg_client_ui_settings_general --> pkg_client_locale @@ -1340,7 +1363,6 @@ flowchart TD pkg_client_ui_subagent --> pkg_client_locale pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_input_trigger pkg_client_ui_subagent --> pkg_client_ui_primitives pkg_client_ui_subagent --> pkg_client_ui_slots pkg_client_ui_subagent --> pkg_invariants @@ -1500,6 +1522,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1560,7 +1583,8 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/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) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1588,7 +1612,7 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1624,6 +1648,7 @@ flowchart TD | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `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), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-slots`](../packages/client/ui-slots), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`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-sidebar`](../packages/client/ui-sidebar), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1633,7 +1658,7 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`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/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 35a5615914..631c2c535e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -142,6 +142,7 @@ flowchart TD pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] + pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] @@ -173,6 +174,8 @@ flowchart TD end subgraph group_context["packages/context"] pkg_agent_instructions["agent-instructions"] + pkg_file_reference["file-reference"] + pkg_file_reference_local["file-reference-local"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -515,6 +518,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants + pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -836,6 +842,11 @@ flowchart TD pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session pkg_agent_instructions --> pkg_tools + pkg_file_reference_local --> pkg_agent + pkg_file_reference_local --> pkg_file_reference + pkg_file_reference_local --> pkg_invariants + pkg_file_reference_local --> pkg_system_prompt + pkg_file_reference_local --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction pkg_session_reference --> pkg_invariants @@ -843,6 +854,7 @@ flowchart TD pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_typert_protocol pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -1006,6 +1018,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants @@ -1013,6 +1026,7 @@ flowchart TD pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_typert_registry pkg_web_app --> pkg_invariants @@ -1263,6 +1277,15 @@ flowchart TD 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_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_client_locale + pkg_client_ui_reference --> pkg_client_runtime + pkg_client_ui_reference --> pkg_client_ui_input_trigger + pkg_client_ui_reference --> pkg_client_ui_slots + pkg_client_ui_reference --> pkg_file_reference + pkg_client_ui_reference --> pkg_invariants + pkg_client_ui_reference --> pkg_session_reference + pkg_client_ui_reference --> pkg_typert_protocol pkg_client_ui_settings_general --> pkg_api_remotes pkg_client_ui_settings_general --> pkg_client_connection pkg_client_ui_settings_general --> pkg_client_locale @@ -1342,7 +1365,6 @@ flowchart TD pkg_client_ui_subagent --> pkg_client_locale pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_input_trigger pkg_client_ui_subagent --> pkg_client_ui_primitives pkg_client_ui_subagent --> pkg_client_ui_slots pkg_client_ui_subagent --> pkg_invariants @@ -1502,6 +1524,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1562,7 +1585,8 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/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) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1590,7 +1614,7 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1626,6 +1650,7 @@ flowchart TD | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `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), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-slots`](../packages/client/ui-slots), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`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-sidebar`](../packages/client/ui-sidebar), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1635,7 +1660,7 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`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/runtime-diagnostics/invariants) | diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index 535685927d..d354125b2a 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: 2380584e8bdc70ac1fee28849c8d09f69d55fd9d -session-reference.zh.md: 974723e95d6f7f68d45ddb8efd65db3fa9470fc4 +session-reference.md: e3ef2c00b7ddbeed9fa3f9d074df436d8c0cc7b5 +session-reference.zh.md: 74063c1525a1f7f67663e9b11148e18902b22748 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index 2380584e8b..e3ef2c00b7 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -158,11 +158,11 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con @Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise /** - * Snapshot all references before enqueue and return one aggregated durable context. + * Snapshot all references for one accepted direct message and return one aggregated durable context. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. - * @param signal - optional cancellation boundary for host request teardown. + * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 974723e95d..74063c1525 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -158,11 +158,11 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con @Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise /** - * Snapshot all references before enqueue and return one aggregated durable context. + * Snapshot all references for one accepted direct message and return one aggregated durable context. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. - * @param signal - optional cancellation boundary for host request teardown. + * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f10329f0f2..5c0ca574bc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -3133,7 +3133,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.selectModel': return this.api.sessions.selectModel(request) case 'session.rename': return this.api.sessions.rename(request) case 'session.fork': return this.api.sessions.fork(request) - case 'session.prompt': return this.api.sessions.prompt(request, signal) + case 'session.prompt': return this.api.sessions.prompt(request) case 'session.attachment': return this.api.sessions.attachment(request) case 'session.updateQueue': return this.api.sessions.updateQueue(request) case 'session.cancel': return this.api.sessions.cancel(request) diff --git a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx index 0a52d552a4..f3343a183f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx @@ -1,5 +1,4 @@ import { memo, useMemo } from 'react' -import { sessionRecallLabels } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' @@ -22,19 +21,6 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ loadImage, fileMentions, useSession, renderSlot, t, }: ChatNodeSeatProps) { const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey)) - const referenceLabelsJson = useSession((snapshot) => { - const index = snapshot.chat.order.indexOf(nodeKey) - if (index <= 0) return '' - const previousKey = snapshot.chat.order[index - 1] - const previous = previousKey === undefined ? undefined : snapshot.chat.nodes.get(previousKey) - if (previous?.kind !== 'context') return '' - const labels = sessionRecallLabels((previous as ChatNode<'context'>).data.source) - return labels.length === 0 ? '' : JSON.stringify(labels) - }) - const referenceLabels = useMemo( - () => referenceLabelsJson === '' ? [] : JSON.parse(referenceLabelsJson) as string[], - [referenceLabelsJson], - ) const routedNode = node as ChatNode | undefined const owner = useMemo(() => node === undefined ? null @@ -46,8 +32,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ forkAt, loadImage, fileMentions, - ...(referenceLabels.length === 0 ? {} : { referenceLabels }), - }, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, referenceLabels]) + }, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions]) if (routedNode === undefined || owner === null) return null // Runtime dispatch owns the correlation: every Node's discriminant is the // keyed-slot entry passed alongside that same Node. TypeScript does not diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a9c7cd6d31..87c48c3b87 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -258,14 +258,14 @@ export function PendingSteeringBubble({ content, loadImage, t }: { /** User and admitted-steering keyed Chat renderer. */ export const UserMessageNodeView = memo(function UserMessageNodeView({ - node, loadImage, referenceLabels, t, + node, loadImage, t, }: ChatNodeViewProps<'user' | 'steering'>) { const data = node.data return ( ( Promise fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined - /** Session-reference labels owned by the immediately preceding recall node. */ - referenceLabels?: readonly string[] | undefined } /** Full props of one registered keyed Chat business renderer. */ 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 085127f9c5..3f9f2b5ef1 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -3,19 +3,29 @@ import type { ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { - contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, + contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, sessionRecallLabels, } from '@deepseek-ai/dsh-client-runtime/client' import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' -type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode +interface ReferencedUserMessageNode extends UserMessageNode { + /** Labels cited by the immediately preceding session-reference context. */ + readonly referenceLabels?: readonly string[] +} + +interface ReferencedSteeringMessageNode extends SteeringMessageNode { + /** Labels cited by the immediately preceding session-reference context. */ + readonly referenceLabels?: readonly string[] +} + +type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { /** Ordinary turn-opening user message. */ - user: UserMessageNode + user: ReferencedUserMessageNode /** User message admitted into an active turn. */ - steering: SteeringMessageNode + steering: ReferencedSteeringMessageNode /** Non-user context injected into model history. */ context: ContextMessageNode } @@ -51,6 +61,11 @@ export const messageDefinition: ConversationNodeDefinition = { } } const claimed = reader.previous('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true + const previous = reader.previous('input-message') + const labels = previous?.state.kind === 'context' && previous.state.seq + 1 === event.seq + ? sessionRecallLabels(previous.state.source) + : [] + const referenceLabels = labels.length === 0 ? {} : { referenceLabels: labels } return claimed ? { kind: 'steering', @@ -59,6 +74,7 @@ export const messageDefinition: ConversationNodeDefinition = { time: event.time, content: event.data.content, source: event.data.source, + ...referenceLabels, } : { kind: 'user', @@ -66,6 +82,7 @@ export const messageDefinition: ConversationNodeDefinition = { time: event.time, content: event.data.content, source: event.data.source, + ...referenceLabels, } }, update: context => context.state, diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx index a6308b07e9..f1e3163c43 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx @@ -61,9 +61,13 @@ function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) anchorSeq: node.seq, location: { kind: 'session' }, visibility: 'visible', - data: node.kind === 'model-retry' ? { attempts: [node], current: node } : node, + data: node.kind === 'model-retry' + ? { attempts: [node], current: node } + : (node.kind === 'user' || node.kind === 'steering') && referenceLabels !== undefined + ? { ...node, referenceLabels } + : node, } - const props = { node: viewNode, t: translate, referenceLabels } as ChatNodeViewProps + const props = { node: viewNode, t: translate } as ChatNodeViewProps switch (node.kind) { case 'user': case 'steering': diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts index c6a33c2f76..a9de47c7f3 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts @@ -514,6 +514,31 @@ describe('built-in conversation node Definitions', () => { }) }) + it('associates session-reference labels inside the adjacent direct-message node', () => { + const referenceSource = { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [ + { sessionId: 'source-a', label: 'Research' }, + { sessionId: 'source-b', label: 'Review' }, + ], + } + const value = assembler([ + at(1, 'user/message', { + ...textMessage('reference-context', 'snapshot'), + source: referenceSource, + }, { surfaceOp: 'append' }), + at(2, 'user/message', textMessage('citing-user', '@Research and @Review'), { surfaceOp: 'append' }), + at(4, 'user/message', textMessage('later-user', 'unrelated'), { surfaceOp: 'append' }), + ]) + + const current = snapshot(value) + const users = [...current.nodes.values()].filter(candidate => candidate.kind === 'user') + expect(users[0]?.data).toMatchObject({ referenceLabels: ['Research', 'Review'] }) + expect(users[1]?.data).not.toHaveProperty('referenceLabels') + }) + it('keeps replacement copies out of Chat business nodes', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index a1c62f669c..a2ccb405e1 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-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 packages/client/ui-reference/README.md -README.md: c9ad377d2757a9dfea97c0660ca56cfb6beb6d20 -README.zh.md: 7e027abfc6237d7260fe00ff82af9a7103281804 +README.md: 12a8e69624c0d7fe28c10ae708466fdda4a8480d +README.zh.md: bfaee51af3947fe794afab3a99df194c543b0082 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index c9ad377d27..12a8e69624 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -6,7 +6,7 @@ Unified Web `@file` and `@session` source. The browser starts the `fileReference File picks insert the natural text defined by the shared `@path` grammar. A file closes completion and adds a trailing space; a directory keeps the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted. -Session picks insert an atomic composer chip whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. The visible chip uses `@label`; serialization never reconstructs identity from that label. Ordinary send delegates the canonical mention to `session.prompt`, where Host-side session-reference preparation owns validation, snapshotting, and model context. +Session picks insert an atomic composer chip whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. The visible chip uses `@label`; serialization never reconstructs identity from that label. Ordinary send carries the canonical mention through `session.prompt`; the session-reference service validates it and captures model context at `agent/pre-step`. The `/client` export is the plugin body (`apply`/`inject`) only; candidate encoding stays internal to the registration effect. @@ -20,6 +20,6 @@ Candidate browsing has no model effect. A selected file or session changes only ## Known Limitations and Deferred Work -- **Candidate failure is intentionally quiet** — one unavailable or failed Remote discovery call yields no rows for that domain, while prompt submission still reports session-reference preparation failures through the ordinary send path. +- **Candidate failure is intentionally quiet** — one unavailable or failed Remote discovery call yields no rows for that domain. A session-reference preparation failure occurs after prompt acceptance and terminates that agent turn. - **No browser-side file scan** — Web completion requires a mounted Host `ctx.fileReferences` provider; the browser cannot fall back to its own filesystem. - **Session search remains metadata-only** — discovery filters session id, cwd, and the latest log-backed title through `ctx.sessionReferenceResolver`; message bodies and full transcripts are not searched. diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index 7e027abfc6..bfaee51af3 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -6,7 +6,7 @@ 选择文件会插入共享 `@path` 语法所定义的自然文本。文件会关闭补全并追加一个尾随空格;目录则让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。 -选择会话会插入一个原子的输入框 chip,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` 提及标记。可见 chip 使用 `@label`;序列化永远不会根据该标签重建身份。普通发送会把规范提及标记交给 `session.prompt`,由宿主侧的会话引用准备负责校验、生成快照和模型上下文。 +选择会话会插入一个原子的输入框 chip,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` mention。可见 chip 使用 `@label`;序列化永远不会根据该标签重建身份。普通发送会通过 `session.prompt` 携带规范 mention,session-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。 `/client` 只导出插件主体(`apply`/`inject`);候选编码保留在注册 effect 内部。 @@ -20,6 +20,6 @@ ## 已知限制与暂缓事项 -- **候选失败有意保持静默**:Remote 发现调用不可用或失败时,该领域不产生候选行;提示词提交仍会通过普通发送路径报告会话引用准备失败。 +- **候选失败有意保持静默**:Remote 发现调用不可用或失败时,该领域不产生候选行。会话引用准备失败发生在提示词接受后,并会终止该 agent 轮次。 - **浏览器侧不扫描文件**:Web 补全需要挂载宿主 `ctx.fileReferences` 提供方;浏览器无法回退到自身文件系统。 - **会话搜索仍仅使用元数据**:发现流程通过 `ctx.sessionReferenceResolver` 筛选 session id、cwd 和以日志为依据的最新标题;不搜索消息主体或完整 transcript(文本记录)。 diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index f838083f63..fb356e73a8 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -9,31 +9,15 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext, InputTriggerServiceContract, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { en, NS, zh, type ReferenceKey } from './locales.ts' -/** - * The two Remote calls this source needs. The generated face wraps every - * business result in {@link RemoteResult}: a carrier failure arrives as the - * `ok: false` branch rather than a rejection, so discovery reads one envelope - * per domain and either domain can fail without hiding the other. - */ -interface ReferenceRemotes { - readonly fileReferences: { - list: (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> - } - readonly sessionReferenceResolver: { - candidates: (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> - } -} - /** Required services: the trigger registry, the Remote namespaces, and the copy. */ export const inject = [ 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', @@ -46,18 +30,17 @@ export const inject = [ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries') const t = ctx.locale.bind(NS) - const remote: ReferenceRemotes = ctx.remote const source: InputTriggerSource = { trigger: '@', name: 'reference', async candidates(session: ClientSessionContext, { query, quoted, signal }) { - const files = remote.fileReferences.list(session.sessionId, query, signal).then( + const files = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( result => result.ok ? result.value : [], () => [], ) const sessions = quoted === true ? Promise.resolve([] as SessionReferenceMentionCandidate[]) - : remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then( + : ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then( result => result.ok ? result.value : [], () => [], ) diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index 5a56be0add..489b884d57 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-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 packages/context/session-reference/README.md -README.md: 706838bfd475c320b589e091467b2974c9cc43ab -README.zh.md: 71e5fdca0755ea481b19aec79ad1e5ec9cf067fb +README.md: 13dfcf2fa99a118efa03f4f183b6b426bf704c2b +README.zh.md: a81f8c62942ca9a9b3b10c1c109c1fb227971ec0 diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 706838bfd4..13dfcf2fa9 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -7,14 +7,14 @@ English | [中文](README.zh.md) ## Public API - `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched. The unary `sessionReferenceResolver/candidates` Remote method serves the same discovery under the configured candidate limit and attaches each candidate's canonical mention, so browser consumers call `ctx.remote.sessionReferenceResolver.candidates` without an API Proxy route. -- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated, identified `UserMessage` context. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`. +- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated, identified `UserMessage` context. The service calls it for canonical mentions in direct user messages after downstream `agent/pre-step` listeners accept the step. - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text. ## Snapshot semantics -Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compaction` source marker from the folded current surface. Separately sourced session-reference messages are injected context and are excluded, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, other plugin-generated user messages except marked compact checkpoints, and unfinished assistant chunks are also excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. +Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source when the target message reaches `agent/pre-step`. A queued message therefore captures the source state at model-step entry, and the resulting context is immutable after that point. Projection keeps only direct-user `user/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compaction` source marker from the folded current surface. Separately sourced session-reference messages are injected context and are excluded, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, other plugin-generated user messages except marked compact checkpoints, and unfinished assistant chunks are also excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. -The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The Web host installs a one-shot `agent/pre-step` listener keyed by the prepared direct message id before delivery. An `enter` decision containing that exact id receives the snapshot immediately before the direct message; an ordinary discard removes the listener, and queue-to-steer relocation preserves the association. The target log therefore records a sourced context `user/message` followed by the readable direct `user/message`. Later source mutation, compaction, or deletion cannot change target replay. +The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The service's outer `agent/pre-step` listener post-processes accepted direct user messages, preserves their message ids, and inserts each snapshot immediately before the message that cited it. Queue edits and queue-to-steer relocation need no reference-specific handling because parsing occurs after the final inbox claim. Invalid mentions, failed reads, cancellation, and budget failures end that turn before its messages enter model-visible history. The target log records a sourced context `user/message` followed by the readable direct `user/message`; source mutation after capture cannot change target replay. ## Configuration diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index 71e5fdca07..a81f8c6294 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -7,14 +7,14 @@ ## 公开 API - `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或以日志为依据的最新标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label;标题不存在或无法读取时回退到会话 id。不搜索消息主体。一元 `sessionReferenceResolver/candidates` Remote 方法在配置的候选上限内提供同一发现能力,并为每个候选附上规范 mention,浏览器消费方直接调用 `ctx.remote.sessionReferenceResolver.candidates`,无需 API Proxy 路由。 -- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合且带标识的 `UserMessage` 上下文。任何无效引用、读取失败、取消或预算失败,都会使准备操作在宿主调用 `followup()` 或 `steer()` 之前失败。 +- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合且带标识的 `UserMessage` 上下文。下游 `agent/pre-step` 监听器接受步骤后,该服务会针对直接用户消息中的规范 mention 调用此方法。 - `encodeSessionReferenceUri()` 与 `decodeSessionReferenceUri()` 实现 `dsh-session:`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)`,`parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。解析器会拒绝显式 Markdown mention 中任何格式错误的 URI;只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。 ## 快照语义 -准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compaction` 源标记。带独立来源的 session-reference 消息属于注入上下文,会被排除以防止快照递归传播。已遮蔽的压缩(compaction)前事件、工具、推理(reasoning)、除已标记 compact 检查点外的其他插件生成 user 消息,以及未完成的 assistant 分片也都会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。 +目标消息到达 `agent/pre-step` 时,准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`。因此,queued 消息在进入模型步骤时捕获源状态,此后生成的上下文保持不变。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compaction` 源标记。带独立来源的 session-reference 消息属于注入上下文,会被排除以防止快照递归传播。已遮蔽的压缩(compaction)前事件、工具、推理(reasoning)、除已标记 compact 检查点外的其他插件生成 user 消息,以及未完成的 assistant 分片也都会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。 -上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。Web 宿主会在交付前安装一个以已准备直接消息 id 为键的一次性 `agent/pre-step` 监听器。包含该确切 id 的 `enter` 决策会在直接消息之前紧邻插入快照;普通丢弃会移除监听器,而从 queue 移动到 steer 会保留两者的关联。目标日志因此会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message`。后续源变更、压缩或删除都无法改变目标回放。 +上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。该服务的外层 `agent/pre-step` 监听器会处理已接受的直接用户消息,保留其消息 id,并把每份快照插入到引用它的消息紧前。解析发生在最终领取收件箱消息之后,因此队列编辑和从 queue 移动到 steer 不需要引用专用处理。无效 mention、读取失败、取消和预算失败会在消息进入面向模型的历史之前结束该轮次。目标日志会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message`;捕获后的源变更无法改变目标回放。 ## 配置 diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index b77ba8ce16..8110f4ead6 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -7,9 +7,9 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionSurfaceSnapshot, SessionTitleObservationResult } from '@deepseek-ai/dsh-session-query' @@ -26,7 +26,7 @@ import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceMentionCandidate, SessionReferenceSource, } from './types.ts' -import { formatSessionReferenceMention } from './uri.ts' +import { formatSessionReferenceMention, parseSessionReferenceText } from './uri.ts' export type * from './types.ts' export type { Config, SessionReferenceErrorCode } from './config.ts' @@ -103,6 +103,46 @@ export class SessionReferenceResolver extends TypertRemoteService { 'SESSION_REFERENCE_INVALID_CONFIG', ) } + ctx.on('agent/pre-step', async ({ agent, signal }, next): Promise => { + const decision = await next() + if (decision.kind === 'reject') return decision + return { + kind: 'enter', + messages: await this.prepareDirectMessages(agent, decision.messages, signal), + } + }, { prepend: true }) + } + + /** + * Replace canonical mentions in direct user messages and place each prepared + * snapshot immediately before the message that cited it. + * @param agent - agent entering the model step. + * @param messages - messages accepted by downstream pre-step listeners. + * @param signal - active turn cancellation. + * @returns messages with session-reference context inserted in citation order. + */ + private async prepareDirectMessages( + agent: Agent, + messages: readonly UserMessage[], + signal: AbortSignal, + ): Promise { + const prepared = await Promise.all(messages.map(async (message): Promise => { + if (message.source.kind !== 'user') return [message] + const references: SessionReferenceInput[] = [] + const content = message.content.map((block): ContentBlock => { + if (block.type !== 'text') return block + const parsed = parseSessionReferenceText(block.text) + references.push(...parsed.references) + return { type: 'text', text: parsed.text } + }) + if (references.length === 0) return [message] + const resolved = await this.prepare(agent, content, references, signal) + const direct = freezeMessage({ ...message, content: resolved.content }) + return resolved.additionalContext === undefined + ? [direct] + : [resolved.additionalContext, direct] + })) + return prepared.flat() } /** @@ -186,11 +226,11 @@ export class SessionReferenceResolver extends TypertRemoteService { } /** - * Snapshot all references before enqueue and return one aggregated durable context. + * Snapshot all references for one accepted direct message and return one aggregated durable context. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. - * @param signal - optional cancellation boundary for host request teardown. + * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ async prepare( diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index d08b7da413..357e8bb0a6 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction' import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -301,6 +301,79 @@ describe('session reference discovery and preparation', () => { }]) }) + it('prepares direct mentions at pre-step and keeps ordinary and plugin messages unchanged', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target')) + const source = ctx.sessions.create(SessionId('source')) + source.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'source fact' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const agent = fakeAgent(target) + const direct = createUserMessage({ + content: [{ + type: 'text', + text: `compare ${formatSessionReferenceMention({ sessionId: source.id, label: 'Research' })} now`, + }], + source: { kind: 'user' }, + }) + const ordinary = createUserMessage({ + content: [{ type: 'text', text: 'ordinary prompt' }], + source: { kind: 'user' }, + }) + const plugin = createUserMessage({ + content: [{ type: 'text', text: formatSessionReferenceMention({ sessionId: source.id, label: 'Ignored' }) }], + source: { kind: 'plugin', plugin: 'test' }, + }) + const signal = new AbortController().signal + + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [direct, ordinary, plugin], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [direct, ordinary, plugin] }), + ) + + expect(decision.kind).toBe('enter') + if (decision.kind !== 'enter') throw new Error('expected entered pre-step') + expect(decision.messages).toHaveLength(4) + expect(decision.messages[0]?.source).toMatchObject({ + kind: 'session-reference', + references: [{ sessionId: source.id, label: 'Research' }], + }) + expect(decision.messages[1]).toMatchObject({ + id: direct.id, + content: [{ type: 'text', text: 'compare @Research now' }], + }) + expect(decision.messages[1]).not.toBe(direct) + expect(decision.messages[2]).toBe(ordinary) + expect(decision.messages[3]).toBe(plugin) + }) + + it('does not prepare a rejected pre-step and rejects malformed direct mentions', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target')) + const agent = fakeAgent(target) + const malformed = createUserMessage({ + content: [{ type: 'text', text: '@[bad](dsh-session:not-canonical)' }], + source: { kind: 'user' }, + }) + const readSurface = vi.spyOn(ctx.sessionQuery, 'readSurface') + const signal = new AbortController().signal + + await expect(agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [malformed], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'reject' as const }), + )).resolves.toEqual({ kind: 'reject' }) + expect(readSurface).not.toHaveBeenCalled() + + await expect(agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [malformed], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [malformed] }), + )).rejects.toThrow(/invalid session reference URI/) + }) + it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target')) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 4e76ca745c..dbd3731614 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -216,7 +216,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to one keyed Chat business renderer. */\nexport interface ChatNodeOwnerProps {\n /** Selected Tool call, when the shared details store names one. */\n selectedCallId?: CallId | undefined\n /** Session workspace root; Tool summaries display paths relative to it. */\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: CallId) => void\n forkAt: (seq: number) => void\n /** Resolve a session-authorized historical image for inline display. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Session-reference labels owned by the immediately preceding recall node. */\n referenceLabels?: readonly string[] | undefined\n}', + '/** Stable owner currency delivered to one keyed Chat business renderer. */\nexport interface ChatNodeOwnerProps {\n /** Selected Tool call, when the shared details store names one. */\n selectedCallId?: CallId | undefined\n /** Session workspace root; Tool summaries display paths relative to it. */\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: CallId) => void\n forkAt: (seq: number) => void\n /** Resolve a session-authorized historical image for inline display. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n}', ], ownerPropsReferences: [ 'ImageAttachmentRef', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 29f47d5ecd..ad7d0dbb62 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1289,8 +1289,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise', - description: 'Snapshot all references before enqueue and return one aggregated durable context.', - parameters: [{ name: 'agent', description: 'target agent; references to it are rejected.' }, { name: 'content', description: 'already host-normalized readable message content.' }, { name: 'references', description: 'structured source sessions in mention order.' }, { name: 'signal', description: 'optional cancellation boundary for host request teardown.' }], + description: 'Snapshot all references for one accepted direct message and return one aggregated durable context.', + parameters: [{ name: 'agent', description: 'target agent; references to it are rejected.' }, { name: 'content', description: 'already host-normalized readable message content.' }, { name: 'references', description: 'structured source sessions in mention order.' }, { name: 'signal', description: 'optional cancellation boundary for the active turn.' }], returns: 'detached content and optional referenced-session context.', }, ], diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 22fb19c837..5826f75f75 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: 69f10f1ed22d95e7c4601754957218df374a66cc -README.zh.md: 40179d51f8024105beafdc7c49e6eb8e70c7a224 +README.md: 607cd4e4176631b64daf4a298b5d86a75ccdce68 +README.zh.md: cdfb5aa65b3ad5b00596487aae6c99e2f9d4e433 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 69f10f1ed2..607cd4e417 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -38,8 +38,6 @@ Session model selection is a session-domain contract. `session.models` returns t `session.prompt` and `subagent.prompt` accept optional request-local `clientTimeZone` provenance. When present, the Host validates and canonicalizes `UTC` or an IANA Area/Location before Agent entry, rejects invalid input with `invalid-time-zone`, and records the canonical value on that exact `user-rpc` message beside its `rpcId`. The value is not Session, connection, create, resume, or fork state; non-browser callers may omit it. -`session.prompt` parses canonical session mentions from normalized text blocks and asks `ctx.sessionReferenceResolver` to prepare every snapshot before enqueue. Parsing, cancellation, validation, reads, and budget enforcement are one admission transaction: failure enqueues no message, while success registers the separately sourced context against the readable prompt's exact message id for insertion immediately before it at pre-step entry. Ordinary discard removes the registration, and moving a queued prompt to steering preserves the association. - Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. Background jobs ride the same live-push posture. When `ctx.jobs` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/jobs` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `JobView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 40179d51f8..cdfb5aa65b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -38,8 +38,6 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.prompt` 和 `subagent.prompt` 接受可选的请求本地 `clientTimeZone` 来源信息。若提供该值,Host 会在进入 Agent 前校验 `UTC` 或 IANA Area/Location 并将其规范化;无效输入以 `invalid-time-zone` 拒绝,规范值则与 `rpcId` 一起记录在这条确切的 `user-rpc` 消息上。该值不属于 Session、连接、create、resume 或 fork 状态;非浏览器调用方可以省略它。 -`session.prompt` 从规范化文本块中解析规范会话提及标记,并要求 `ctx.sessionReferenceResolver` 在入队前准备每个快照。解析、取消、校验、读取和预算约束共同构成一个准入事务:失败时不会有消息入队;成功时,会以可读提示词的确切消息 id 注册独立来源上下文,等待 pre-step 进入时紧邻插入到它之前。普通丢弃会移除该注册,把 queued 提示词移动到 steering(中途引导)时仍会保留两者的关联。 - 待处理的 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()` 修改队列。认领操作的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 后台任务沿用同一种实时推送姿态。当组合中有 `ctx.jobs` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/jobs` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `JobView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 8c8bd1e05b..946e27ded3 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -58,7 +58,6 @@ "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", @@ -89,6 +88,7 @@ "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^" + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8a8b3a8506..c675b60907 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -8,7 +8,7 @@ import { mkdir, stat } from 'node:fs/promises' import { dirname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' -import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus, PreStepDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' @@ -18,10 +18,6 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { - parseSessionReferenceText, - type SessionReferenceInput, -} from '@deepseek-ai/dsh-session-reference' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' @@ -86,7 +82,7 @@ import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@dee import { credentialRef } from '@deepseek-ai/dsh-credentials' // Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`. import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' -import type { CallId, MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' // Side-effect type import: resolves the `approval/request` waterfall and @@ -165,21 +161,6 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten return blocks } -/** Remove canonical session mentions from text blocks and retain their structured identities. */ -function parseReferencedContent(content: readonly PromptContentPart[]): { - content: PromptContentPart[] - references: SessionReferenceInput[] -} { - const references: SessionReferenceInput[] = [] - const normalized = content.map((part): PromptContentPart => { - if (part.type !== 'text') return part - const parsed = parseSessionReferenceText(part.text) - references.push(...parsed.references) - return { type: 'text', text: parsed.text } - }) - return { content: normalized, references } -} - /** Search durable 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 @@ -1085,72 +1066,6 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie } } -/** One ApiProxy instance's pending reference-prompt admission listeners. */ -interface PreparedPromptOwnership { - readonly relocating: Set - readonly cleanups: Map void> -} - -/** Deliver a prepared prompt and inject its snapshot immediately before that exact message enters. */ -function deliverPrompt( - ctx: Context, - agent: Agent, - mode: 'queue' | 'steer', - message: UserMessage, - additionalContext: UserMessage | undefined, - ownership: PreparedPromptOwnership, -): void { - if (additionalContext === undefined) { - if (mode === 'steer') agent.steer(message) - else agent.followup(message) - return - } - let cleanedUp = false - let detachPreStep = (): void => {} - let detachDiscard = (): void => {} - let detachDisposed = (): void => {} - const cleanup = (): void => { - /* v8 ignore next -- all settlement paths share this idempotent release. */ - if (cleanedUp) return - cleanedUp = true - ownership.cleanups.delete(message.id) - detachPreStep() - detachDiscard() - detachDisposed() - } - ownership.cleanups.set(message.id, cleanup) - // An agent retired with the prepared prompt still pending must not leave - // these listeners on the Host root context for the process lifetime. - detachDisposed = ctx.on('agent/disposed', ({ agent: subject }) => { - if (subject === agent) cleanup() - }) - detachPreStep = ctx.on('agent/pre-step', async ({ agent: subject, messages }, next): Promise => { - if (subject !== agent || !messages.some(candidate => candidate.id === message.id)) return next() - cleanup() - const decision = await next() - if (decision.kind !== 'enter') return decision - const promptIndex = decision.messages.findIndex(candidate => candidate.id === message.id) - if (promptIndex < 0) return decision - return { - kind: 'enter', - messages: decision.messages.toSpliced(promptIndex, 0, additionalContext), - } - }, { prepend: true }) - detachDiscard = ctx.on('agent/inbox/discarded', ({ agent: subject, message: discarded }) => { - if (subject !== agent || discarded.id !== message.id || ownership.relocating.has(message.id)) return - const remainsPending = [...agent.inbox.nextTurn, ...agent.inbox.nextStep] - .some(candidate => candidate.id === message.id) - if (!remainsPending) cleanup() - }) - try { - if (mode === 'steer') agent.steer(message) - else agent.followup(message) - } catch (error: unknown) { - cleanup() - throw error - } -} - /** * Implement ApiProxy over a composed host context. * @param ctx - a context with the Host spine and Workspace registry mounted. @@ -1185,10 +1100,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const pendingApprovals = new Map() const muxQueues = new Set>>() const imageAdmissionChains = new WeakMap>() - const preparedPromptOwnership: PreparedPromptOwnership = { - relocating: new Set(), - cleanups: new Map(), - } /** Serialize image admission with model selection for one agent. */ function serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { @@ -2487,7 +2398,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { sessionId: childId }) }, - async prompt(request, signal) { + async prompt(request) { const { sessionId, mode, content, clientTimeZone } = request.payload const canonicalTimeZone = clientTimeZone === undefined ? undefined @@ -2502,32 +2413,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) if ('refused' in resolved) return resolved.refused const agent = resolved.agent - let parsed: ReturnType - try { - parsed = parseReferencedContent(content) - } catch (error: unknown) { - return err(request, { - code: 'reference-invalid', - message: 'invalid session reference', - details: { reason: String(error) }, - }) - } // Request identity and optional browser zone ride the exact durable user message. const source: MessageSource = { kind: 'user', rpcId: request.rpcId, ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), } - const hasImage = parsed.content.some(part => part.type === 'image') + const hasImage = content.some(part => part.type === 'image') const admit = async (): Promise> => { try { - if (signal?.aborted === true) { - return err(request, { - code: 'cancelled', - message: 'prompt submission was aborted', - details: {}, - }) - } if (hasImage) { const current = selectionFor(agent).current const modelInfo = await ctx.llm.resolveModelInfo(current.provider, current.model) @@ -2539,45 +2433,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } } - let durable = await durablePromptContent(ctx, parsed.content) - let additionalContext: UserMessage | undefined - if (parsed.references.length > 0) { - const sessionReferences = ctx.get('sessionReferenceResolver') - if (sessionReferences === undefined) { - return err(request, { - code: 'reference-unavailable', - message: 'session reference capability unavailable', - details: { kind: 'session' }, - }) - } - try { - const prepared = await sessionReferences.prepare(agent, durable, parsed.references, signal) - durable = prepared.content - additionalContext = prepared.additionalContext - } catch (error: unknown) { - if (signal !== undefined && isAborted(signal)) { - return err(request, { - code: 'cancelled', - message: 'session reference preparation was aborted', - details: {}, - }) - } - return err(request, { - code: 'reference-failed', - message: 'session reference preparation failed', - details: { reason: String(error) }, - }) - } - } - if (signal !== undefined && isAborted(signal)) { - return err(request, { - code: 'cancelled', - message: 'prompt submission was aborted', - details: {}, - }) - } + const durable = await durablePromptContent(ctx, content) const message: UserMessage = createUserMessage({ content: durable, source }) - deliverPrompt(ctx, agent, mode, message, additionalContext, preparedPromptOwnership) + if (mode === 'steer') agent.steer(message) + else agent.followup(message) } catch (error: unknown) { if (error instanceof AttachmentError) { return err(request, { @@ -2690,16 +2549,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (action.kind === 'edit') { agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content })) } else { - if (action.kind === 'steer') preparedPromptOwnership.relocating.add(itemId) - try { - agent.inbox.remove(itemId) - if (action.kind === 'steer') agent.steer(message) - } catch (error: unknown) { - preparedPromptOwnership.cleanups.get(itemId)?.() - throw error - } finally { - preparedPromptOwnership.relocating.delete(itemId) - } + agent.inbox.remove(itemId) + if (action.kind === 'steer') agent.steer(message) } return Promise.resolve(ok(request, { accepted: true as const })) }, diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 7c2d80fcef..03cfdd1e15 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -53,9 +53,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('reference-unavailable'), message: z.string(), details: z.object({ kind: z.union([z.literal('file'), z.literal('session')]) }) }), - z.object({ code: z.literal('reference-invalid'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('reference-failed'), 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('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 05431eca08..0b5506b6b6 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -51,9 +51,6 @@ export interface RpcErrorDetailsMap { 'agent-preset-not-found': { agentPreset: string; available: string[] } 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } - 'reference-unavailable': { kind: 'file' | 'session' } - 'reference-invalid': { reason: string } - 'reference-failed': { reason: string } 'attachment-error': { reason: string } 'queue-item-not-found': { itemId: MessageId } 'steer-unavailable': { itemId: MessageId } diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 24f91bf552..2e7c9b22f4 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -349,7 +349,7 @@ export interface SessionsApi { mode: 'queue' | 'steer' content: PromptContentPart[] clientTimeZone?: string - }>, signal?: AbortSignal): + }>): Promise> /** Reads one durable image after proving that this session's log references its id. */ diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d33e8dc55b..697171ec53 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -96,7 +96,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) }, 'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) }, 'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) }, - 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r, signal) => api.sessions.prompt(r, signal) }, + 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) }, 'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, @@ -172,6 +172,9 @@ function fullResponse(narrow: RpcResponse): Response { * Wire<> widening back to the exact payload (undefined-valued properties and * absent ones are indistinguishable after JSON transport). */ +// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own +// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection. +// oxlint-disable-next-line typescript/no-unnecessary-type-parameters async function handleUnary( api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal, ): Promise { diff --git a/packages/host/apiproxy/tests/api-proxy-references.spec.ts b/packages/host/apiproxy/tests/api-proxy-references.spec.ts deleted file mode 100644 index 921440d7f2..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-references.spec.ts +++ /dev/null @@ -1,412 +0,0 @@ -/** - * Referenced prompt coverage over the real ApiProxy: atomic snapshot - * preparation before enqueue and error/cancellation behavior. Discovery lives - * on the owning services' Remote faces, tested in their packages. - */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' -import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import type { SessionId } from '@deepseek-ai/dsh-session' -import { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' -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 = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } -let nextRpc = 1 - -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`reference-${String(nextRpc++)}`), payload } -} - -function expectOk(response: RpcResponse): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -function expectErr(response: RpcResponse): { code: string; message: string } { - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - return response.result.error -} - -async function harness(): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(UserQuestionService) - await ctx.plugin(AgentRegistry) - ctx.provide('workspace', { list: () => [] } as never) - return ctx -} - -function stubAgent(ctx: Context, status: Agent['status'] = 'idle') { - const session = ctx.sessions.create(undefined, { meta: { cwd: '/project' } }) - const followup = vi.fn() - const steer = vi.fn() - const inject = vi.fn() - const inbox = new Inbox(session, { - inserted(message) { - agentEvents(ctx, agent).emit('agent/inbox/inserted', { message }) - }, - discarded(message) { - agentEvents(ctx, agent).emit('agent/inbox/discarded', { message }) - }, - claimed(message, turn) { - agentEvents(ctx, agent).emit('agent/inbox/claimed', { message, turn }) - }, - }) - const agent = { - id: session.id, - session, - inbox, - status, - ctx, - followup, - steer, - inject, - cancel: vi.fn(), - } as unknown as Agent & { - followup: typeof followup - steer: typeof steer - inject: typeof inject - } - followup.mockImplementation((message) => { inbox.append('next-turn', message) }) - steer.mockImplementation((message) => { inbox.append('next-step', message) }) - inject.mockImplementation((message) => { inbox.append('next-step', message) }) - ctx.agents.register(agent) - return agent -} - -describe('referenced prompt preparation', () => { - it('normalizes the visible mention and waits for all context preparation before enqueue', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const earlier = createUserMessage({ - source: { kind: 'user' }, - content: [{ type: 'text', text: 'earlier queued prompt' }], - }) - agent.followup(earlier) - const source = 'source-session' as SessionId - const mention = formatSessionReferenceMention({ sessionId: source, label: 'Research' }) - let finish!: () => void - const context = createUserMessage({ - source: { - kind: 'session-reference' as const, - form: 'recall' as const, - version: 1 as const, - references: [{ - sessionId: source, - label: 'Research', - capturedThroughSeq: null, - compacted: false, - originalMessages: 1, - retainedMessages: 1, - omittedMessages: 0, - omittedBytes: 0, - truncated: false, - inputIndex: 0, - }], - }, - content: [{ type: 'text' as const, text: 'snapshot' }], - }) - const prepare = vi.fn(() => new Promise<{ - content: { type: 'text'; text: string }[] - additionalContext: typeof context - }>((resolve) => { - finish = () => { - resolve({ - content: [{ type: 'text', text: 'compare @Research now' }], - additionalContext: context, - }) - } - })) - ctx.provide('sessionReferenceResolver', { prepare } as never) - const api = createApiProxy(ctx, DEFAULTS) - const signal = new AbortController().signal - const pending = api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ type: 'text' as const, text: `compare ${mention} now` }], - mode: 'queue' as const, - }), signal) - await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() }) - expect(agent.followup).toHaveBeenCalledTimes(1) - expect(prepare).toHaveBeenCalledWith( - agent, - [{ type: 'text', text: 'compare @Research now' }], - [{ sessionId: source, label: 'Research' }], - signal, - ) - finish() - expect(expectOk(await pending)).toEqual({ accepted: true }) - expect(agent.followup).toHaveBeenCalledTimes(2) - const sent = agent.followup.mock.calls[1]?.[0] - expect(sent).toMatchObject({ - content: [{ type: 'text', text: 'compare @Research now' }], - source: { kind: 'user' }, - }) - if (sent === undefined) throw new Error('expected queued prompt') - const firstBatch = agent.inbox.claim('next-turn', 1) - const firstDecision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: firstBatch, turn: 1, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: firstBatch }), - ) - expect(firstDecision).toEqual({ kind: 'enter', messages: [earlier] }) - const referencedBatch = agent.inbox.claim('next-turn', 2) - const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: referencedBatch, turn: 2, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: referencedBatch }), - ) - expect(decision).toEqual({ kind: 'enter', messages: [context, sent] }) - const replay = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: referencedBatch, turn: 2, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: referencedBatch }), - ) - expect(replay).toEqual({ kind: 'enter', messages: referencedBatch }) - }) - - it('inserts prepared session context immediately before steering at admission', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const source = 'source-session' as SessionId - const context = createUserMessage({ - source: { - kind: 'session-reference' as const, - form: 'recall' as const, - version: 1 as const, - references: [{ - sessionId: source, - label: 'Research', - capturedThroughSeq: null, - compacted: false, - originalMessages: 1, - retainedMessages: 1, - omittedMessages: 0, - omittedBytes: 0, - truncated: false, - inputIndex: 0, - }], - }, - content: [{ type: 'text' as const, text: 'snapshot' }], - }) - ctx.provide('sessionReferenceResolver', { - prepare: () => Promise.resolve({ - content: [{ type: 'text' as const, text: 'continue @Research' }], - additionalContext: context, - }), - } as never) - const api = createApiProxy(ctx, DEFAULTS) - const response = await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ - type: 'text' as const, - text: `continue ${formatSessionReferenceMention({ sessionId: source, label: 'Research' })}`, - }], - mode: 'steer' as const, - })) - expect(expectOk(response)).toEqual({ accepted: true }) - const steered = agent.steer.mock.calls[0]?.[0] - expect(steered?.content).toEqual([{ type: 'text', text: 'continue @Research' }]) - expect(steered?.source.kind).toBe('user') - if (steered === undefined) throw new Error('expected steering prompt') - const signal = new AbortController().signal - const batch = agent.inbox.claim('next-step', 1) - const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: batch, turn: 1, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: batch }), - ) - expect(decision).toEqual({ kind: 'enter', messages: [context, steered] }) - expect(agent.inject).not.toHaveBeenCalled() - }) - - it('releases the admission listeners when the agent is disposed with the prompt pending', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const source = 'source-session' as SessionId - const context = createUserMessage({ - source: { kind: 'plugin' as const, plugin: 'session-reference' }, - content: [{ type: 'text' as const, text: 'snapshot' }], - }) - ctx.provide('sessionReferenceResolver', { - prepare: () => Promise.resolve({ - content: [{ type: 'text' as const, text: 'continue @Research' }], - additionalContext: context, - }), - } as never) - const api = createApiProxy(ctx, DEFAULTS) - expectOk(await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ - type: 'text' as const, - text: `continue ${formatSessionReferenceMention({ sessionId: source, label: 'Research' })}`, - }], - mode: 'queue' as const, - }))) - const queued = agent.followup.mock.calls[0]?.[0] - if (queued === undefined) throw new Error('expected queued prompt') - - agentEvents(ctx, agent).emit('agent/disposed', { agent }) - - const signal = new AbortController().signal - const batch = agent.inbox.claim('next-turn', 1) - const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: batch, turn: 1, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: batch }), - ) - expect(decision).toEqual({ kind: 'enter', messages: [queued] }) - }) - - it('keeps prepared context paired when a queued prompt moves to steering', async () => { - const ctx = await harness() - const agent = stubAgent(ctx, 'running') - const source = 'source-session' as SessionId - const context = createUserMessage({ - source: { - kind: 'session-reference' as const, - form: 'recall' as const, - version: 1 as const, - references: [{ - sessionId: source, - label: 'Research', - capturedThroughSeq: null, - compacted: false, - originalMessages: 1, - retainedMessages: 1, - omittedMessages: 0, - omittedBytes: 0, - truncated: false, - inputIndex: 0, - }], - }, - content: [{ type: 'text' as const, text: 'snapshot' }], - }) - ctx.provide('sessionReferenceResolver', { - prepare: () => Promise.resolve({ - content: [{ type: 'text' as const, text: 'continue @Research' }], - additionalContext: context, - }), - } as never) - const api = createApiProxy(ctx, DEFAULTS) - expect(expectOk(await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ - type: 'text' as const, - text: formatSessionReferenceMention({ sessionId: source, label: 'Research' }), - }], - mode: 'queue' as const, - })))).toEqual({ accepted: true }) - const queued = agent.inbox.nextTurn[0] - if (queued === undefined) throw new Error('expected queued reference prompt') - - expect(expectOk(await api.sessions.updateQueue(request({ - sessionId: agent.id, - itemId: queued.id, - action: { kind: 'steer' as const }, - })))).toEqual({ accepted: true }) - expect(agent.inbox.nextTurn).toEqual([]) - expect(agent.inbox.nextStep).toEqual([queued]) - - const signal = new AbortController().signal - const batch = agent.inbox.claim('next-step', 1) - const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: batch, turn: 1, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: batch }), - ) - expect(decision).toEqual({ kind: 'enter', messages: [context, queued] }) - }) - - it.each(['queue', 'steer'] as const)( - 'does not deliver a %s prompt when preparation resolves after cancellation', - async (mode) => { - const ctx = await harness() - const agent = stubAgent(ctx) - const controller = new AbortController() - const source = 'source-session' as SessionId - const mention = formatSessionReferenceMention({ sessionId: source, label: 'Research' }) - ctx.provide('sessionReferenceResolver', { - prepare: async () => { - controller.abort() - return { - content: [{ type: 'text' as const, text: '@Research' }], - additionalContext: { - source: { - kind: 'session-reference' as const, - form: 'recall' as const, - version: 1 as const, - references: [{ - sessionId: source, - label: 'Research', - capturedThroughSeq: null, - compacted: false, - originalMessages: 1, - retainedMessages: 1, - omittedMessages: 0, - omittedBytes: 0, - truncated: false, - inputIndex: 0, - }], - }, - content: [{ type: 'text' as const, text: 'snapshot' }], - }, - } - }, - } as never) - const api = createApiProxy(ctx, DEFAULTS) - - const response = await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ type: 'text' as const, text: mention }], - mode, - }), controller.signal) - - expect(expectErr(response).code).toBe('cancelled') - expect(agent.followup).not.toHaveBeenCalled() - expect(agent.steer).not.toHaveBeenCalled() - expect(agent.inject).not.toHaveBeenCalled() - }, - ) - - it('rejects malformed mentions and preparation failures without enqueueing any prompt', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const prepare = vi.fn(() => Promise.reject(new Error('snapshot unavailable'))) - ctx.provide('sessionReferenceResolver', { prepare } as never) - const api = createApiProxy(ctx, DEFAULTS) - - const malformed = await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ type: 'text' as const, text: '@[bad](dsh-session:not-canonical)' }], - mode: 'queue' as const, - })) - expect(expectErr(malformed).code).toBe('reference-invalid') - expect(prepare).not.toHaveBeenCalled() - expect(agent.followup).not.toHaveBeenCalled() - - const mention = formatSessionReferenceMention({ - sessionId: 'source-session' as SessionId, - label: 'Research', - }) - const failed = await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ type: 'text' as const, text: mention }], - mode: 'queue' as const, - })) - expect(expectErr(failed).code).toBe('reference-failed') - expect(agent.followup).not.toHaveBeenCalled() - expect(agent.steer).not.toHaveBeenCalled() - }) -}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 8902715c6b..66ae52f783 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -73,9 +73,6 @@ describe('rpcErrorSchema', () => { details: { provider: 'p', model: 'm' }, }).code).toBe('model-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') - expect(rpcErrorSchema.parse({ code: 'reference-unavailable', message: 'm', details: { kind: 'session' } }).code).toBe('reference-unavailable') - expect(rpcErrorSchema.parse({ code: 'reference-invalid', message: 'm', details: { reason: 'r' } }).code).toBe('reference-invalid') - expect(rpcErrorSchema.parse({ code: 'reference-failed', message: 'm', details: { reason: 'r' } }).code).toBe('reference-failed') expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') @@ -87,8 +84,6 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'reference-unavailable', message: 'm', details: { kind: 'disk' } })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'reference-failed', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 6abd799c0e..3ed38b3d77 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -62,9 +62,6 @@ { "path": "../../session-query/session-query" }, - { - "path": "../../context/session-reference" - }, { "path": "../../session/session-title" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31610647f4..7fa0b63991 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4862,9 +4862,6 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query - '@deepseek-ai/dsh-session-reference': - specifier: workspace:^ - version: link:../../context/session-reference '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title From 9eab23c17688f1c5bb996b0c91bb2b41635b8dab Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 21:17:30 +0800 Subject: [PATCH 046/180] =?UTF-8?q?fix(release):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=8C=85=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/client/ui-reference/package.json | 2 +- packages/code-runtime/code-runtime-python/package.json | 2 +- packages/context/file-reference-local/package.json | 2 +- packages/context/file-reference/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index f5133b54c5..fefc2a2ad2 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index 2b7734dc94..7cea7a25b5 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 48bab806ba..e9bd72702d 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 50ecf97ba1..84a4ef58da 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, From fdf6fec5c023b8fda4b5e76592b004e13aa25c9a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 21:25:12 +0800 Subject: [PATCH 047/180] =?UTF-8?q?test(session-reference):=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E9=A2=84=E5=A4=84=E7=90=86=E5=88=86=E6=94=AF=E8=A6=86?= =?UTF-8?q?=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/context/session-reference/src/index.ts | 8 +++++--- .../session-reference/tests/session-reference.spec.ts | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 8110f4ead6..433ee1d0b1 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -138,9 +138,11 @@ export class SessionReferenceResolver extends TypertRemoteService { if (references.length === 0) return [message] const resolved = await this.prepare(agent, content, references, signal) const direct = freezeMessage({ ...message, content: resolved.content }) - return resolved.additionalContext === undefined - ? [direct] - : [resolved.additionalContext, direct] + /* v8 ignore if -- a parsed canonical mention always leaves one normalized reference */ + if (resolved.additionalContext === undefined) { + throw new Error('session-reference preparation omitted context for a canonical mention') + } + return [resolved.additionalContext, direct] })) return prepared.flat() } diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 357e8bb0a6..e1eac75658 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -314,7 +314,7 @@ describe('session reference discovery and preparation', () => { content: [{ type: 'text', text: `compare ${formatSessionReferenceMention({ sessionId: source.id, label: 'Research' })} now`, - }], + }, { type: 'reasoning', text: 'preserve this non-text block' }], source: { kind: 'user' }, }) const ordinary = createUserMessage({ @@ -342,7 +342,10 @@ describe('session reference discovery and preparation', () => { }) expect(decision.messages[1]).toMatchObject({ id: direct.id, - content: [{ type: 'text', text: 'compare @Research now' }], + content: [ + { type: 'text', text: 'compare @Research now' }, + { type: 'reasoning', text: 'preserve this non-text block' }, + ], }) expect(decision.messages[1]).not.toBe(direct) expect(decision.messages[2]).toBe(ordinary) From 8d17e63558f90ef7c0e82c3c931e488bd7959ba0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 21:29:14 +0800 Subject: [PATCH 048/180] =?UTF-8?q?chore(ci):=20=E5=88=B7=E6=96=B0=20PR=20?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a8a028e26fa8bd7812e4224cde7df85f9d7f1faf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 23:31:36 +0800 Subject: [PATCH 049/180] test: avoid Windows metadata-version collision --- .../agent-instructions/tests/agent-instructions.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 2bdee49988..5cd25da780 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -3267,7 +3267,7 @@ describe('dynamic nested workspace context injection', () => { try { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'pkg/AGENTS.md'), 'canonical nested rule') - await write(join(root, 'pkg/CLAUDE.md'), 'divergent nested rule') + await write(join(root, 'pkg/CLAUDE.md'), 'distinct nested instruction') await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) @@ -3279,7 +3279,7 @@ describe('dynamic nested workspace context injection', () => { }) const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) expect(firstText).toContain('canonical nested rule') - expect(firstText).toContain('divergent nested rule') + expect(firstText).toContain('distinct nested instruction') await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule') await ctx.tools.execute({ From 884f7b9c41f292b1715c79399eb8aec04843df4d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 11:24:33 +0800 Subject: [PATCH 050/180] fix(llm-pi-ai): expose the pi-ai wire-compat surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi-ai infers a request's shape from the provider id and baseURL, and for an endpoint it does not recognize it answers as though it were OpenAI itself. A hand-declared route is by construction such an endpoint, so a model declaring reasoningEfforts sent its system prompt as the developer role with no configuration able to say otherwise — a gateway rejecting that role could not be connected at all. Writing the switch anyway validated, persisted, and was then dropped, so the misconfiguration looked applied. Three drift gates classify all thirty upstream compat fields as offered or withheld, keyed by `keyof` so a pi-ai upgrade fails the build until the new field is classified. Twenty are offered: what a private URL cannot imply. The rest stay withheld because pi-ai's installed catalog sets them for a named vendor. Protocol applicability is now per field rather than per block, so supportsDeveloperRole reaches an openai-responses route and the anthropic-messages switches reach theirs. A compat key no protocol declares, or one a gate withholds, is refused where it is written. Fixes #2646 Refs #1976 --- ...per-model-reasoning-declarations.i18n.yaml | 4 +- ...-pi-ai-per-model-reasoning-declarations.md | 2 +- ...-ai-per-model-reasoning-declarations.zh.md | 2 +- ...-08-18-pi-ai-wire-compat-surface.i18n.yaml | 6 + .../2026-08-18-pi-ai-wire-compat-surface.md | 51 +++ ...2026-08-18-pi-ai-wire-compat-surface.zh.md | 51 +++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 92 +++-- docs/config-catalog.zh.md | 92 +++-- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 11 +- packages/llm/llm-pi-ai/README.zh.md | 11 +- packages/llm/llm-pi-ai/src/catalog.ts | 355 +++++++++++++++--- packages/llm/llm-pi-ai/src/config.ts | 47 ++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 40 ++ packages/llm/llm-pi-ai/tests/catalog.spec.ts | 123 +++++- 16 files changed, 762 insertions(+), 133 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml index 3639c8da6b..b7f4e2e031 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages 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-pi-ai-per-model-reasoning-declarations.md -2026-08-08-pi-ai-per-model-reasoning-declarations.md: b6264feeb724e3693078fa3fc3e3fc16ed01aacb -2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 1b30f7e0c42974c777a535e133a47caa217e2e5e +2026-08-08-pi-ai-per-model-reasoning-declarations.md: 0e8d5c3ca4017e89332f6b22e4eb0a06062918e6 +2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: c4c060b21c8b7ee5b11b5e98c8d7da0ba6c032fc diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md index b6264feeb7..0e8d5c3ca4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md @@ -14,7 +14,7 @@ Two adjacent gaps compounded this. pi-ai decides the reasoning *wire dialect* (` `PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, no Off is offered and an explicit Off request is refused (an effortless request still goes out bare, leaving the provider its default); declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. -`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). +`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. `thinkingFormat` is pinned to pi-ai's union through a `Record` drift gate, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). Which fields `compat` carries, which protocols take each of them, and how an unreadable key is refused are owned by [[2026-08-18-pi-ai-wire-compat-surface]]; the two-level resolution order above is what that surface generalizes. `modelOverrides` reshapes individual catalog models without replacing the served set: key = catalog model id, value = a `models` entry minus `id`, materialized by handing the override to the existing entry path so capacities, efforts, compat, and request-default semantics stay identical. Unlike Pi's own config layer, which ignores unknown ids, every override that lands nowhere is refused — beside a `models` list, on a hand-declared route, naming an unknown model, or smuggling an `id` in the value (the schema passes unknown keys through, and a smuggled id would quietly rename the model). diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md index 1b30f7e0c4..c4c060b21c 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md @@ -14,7 +14,7 @@ Status: implemented `PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,选择器不提供 Off,显式请求 Off 会被拒绝(不点名档位的请求仍会不带参数地发出,提供方保留自己的默认行为);声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 -`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。 +`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。`thinkingFormat` 经 `Record` 漂移门禁钉在 pi-ai 的联合类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。`compat` 承载哪些字段、每个字段由哪些协议接受、以及无法读取的键如何被拒绝,归 [[2026-08-18-pi-ai-wire-compat-surface]] 所有;上面这条两级解析顺序正是该面所推广的东西。 `modelOverrides` 就地重塑单个 catalog 模型而不替换所服务的集合:键 = catalog 模型 id,值 = 去掉 `id` 的 `models` 条目,物化时把覆盖交给既有的条目路径,因此容量、档位、compat 与请求默认值语义完全一致。与忽略未知 id 的 Pi 自有配置层不同,凡是落不到任何地方的覆盖都会被拒绝——与 `models` 列表并存、写在手工声明的路由上、点名未知模型,或在值里夹带 `id`(schema 会放行未知键,被夹带的 id 会悄悄把模型改名)。 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml new file mode 100644 index 0000000000..0a36513613 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.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-18-pi-ai-wire-compat-surface.md +2026-08-18-pi-ai-wire-compat-surface.md: c7e0bc75806e8ba022db9c9f17cfe2b621c21611 +2026-08-18-pi-ai-wire-compat-surface.zh.md: c592044e239421110813feb364bacc142a9d2d32 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md new file mode 100644 index 0000000000..c7e0bc7580 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md @@ -0,0 +1,51 @@ +# Agent Note: pi-ai Wire-Compatibility Surface in llm-pi-ai + +Status: implemented + +English | [中文](2026-08-18-pi-ai-wire-compat-surface.zh.md) + +## Problem + +pi-ai shapes every request from the provider id and the baseURL — which role carries the system prompt, which field caps output, whether `store` and `stream_options` go out, whether tool definitions carry `strict`. For an endpoint its detection does not recognize, the answer is "this is OpenAI itself": `detectCompat` returns `supportsDeveloperRole: true`, `maxTokensField: "max_completion_tokens"`, `supportsStore: true`. A hand-declared route is by construction an endpoint pi-ai does not ship, so every such route received OpenAI's own request shape. + +The adapter offered two of pi-ai's thirty compat fields ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] scoped them to "the switches pi-ai's reasoning dispatch reads"), and `supportsDeveloperRole` fell inside that scope while being absent from it: its send site is `model.reasoning && compat.supportsDeveloperRole`. A hand-declared model declaring `reasoningEfforts` therefore sent its system prompt as `role: "developer"`, which most OpenAI-compatible gateways reject, and no configuration could say otherwise — the gateway could not be connected at all. + +Writing the field anyway was worse than unsupported. schemastery passes unknown keys through, and resolution read only two names, so `compat: {supportsDeveloperRole: false}` validated, persisted, and was then dropped: the operator saw an accepted write and an unchanged failure. `maxTokensField` carried the same defect over a wider blast radius, since it shapes every request rather than only a reasoning model's. + +## Decision + +Three drift gates — one per pi-ai compat type, keyed `Record` — classify all thirty upstream fields as `offer` or `withhold`. Twenty are offered. The line is what a private URL can imply: a deployment must be able to state what nothing can infer from an unrecognized endpoint, while a field pi-ai's installed catalog sets for a named vendor stays withheld, because a route reaching for `openRouterRouting` or `deferredToolsMode` is a catalog route that should be named as such and inherit the value. + +`PiAiCompatProfile` stays an explicit interface with per-field JSDoc — it is what a configuration surface renders and what `docs/config-catalog.md` pastes — and a type-level `AssertNever` over the symmetric difference proves it names exactly the offered set. The schemastery schema is declared `z`, so the four faces lock together: an upstream field added, a gate entry missing, an interface field forgotten, or a schema key omitted each fails compilation naming the field. + +Protocol applicability is per field rather than per block. `supportsDeveloperRole` is settable wherever pi-ai declares it (`openai-completions` and `openai-responses`), `thinkingFormat` only on the former, `supportsTemperature` only on `anthropic-messages`. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it. `chatTemplateKwargs` is offered, which is what makes the two `chat-template` thinking formats nameable. + +A `compat` key no protocol declares, and one a gate withholds, are both refused where they are written rather than dropped. The check runs over every key before any protocol resolves, so a misspelling fails even on a route whose models never reach the protocol that would have taken it. It reads raw keys deliberately: a withheld or undeclared name is absent from the schema, so schemastery cannot have materialized it and a person wrote it. Fields carrying a value are then filtered separately, because schemastery materializes an absent dict as `{}` and `chatTemplateKwargs` is present on every parsed profile whether or not anyone wrote one. + +## Where a refusal lands + +Every check runs in `resolveProfiles`, which no request path re-enters: the adapter memoizes by raw-snapshot identity and `apply` resolves once eagerly. A refusal therefore reaches `settings.mutate` as `settings-rejected` before persistence, a `cordis.yml` `config:` block as a failed plugin mount, and a stored section as a failed `settings.register` at startup. + +An external edit to the settings file is the one path that cannot report: the provider watcher calls `publish()`, which catches a failing section, logs `settings: keeping last good "%s"`, and leaves the namespace serving its previous value. That is the settings seam's behavior for every schema and validator failure, not something this surface introduces, and closing it belongs to that seam rather than here. What changes for compat is the failure model, not the reporting: a key that formerly stayed inert forever now stops the next start. + +## Alternatives considered + +**Add `supportsDeveloperRole` alone.** It fixes the reported gateway and leaves `maxTokensField` — which shapes every request, not only a reasoning model's — breaking a whole class of endpoints, with the next upstream addition free to lag silently again. + +**Offer every upstream field.** pi-ai's own custom-provider documentation converges on a far smaller set, its flagship example naming six, and the remainder are vendor-bound switches its catalog already sets. Exposing `zaiToolStream` or `vercelGatewayRouting` on a hand-declared route offers a knob whose correct use is to not be a hand-declared route. + +**Key `compat` by protocol** (`compat: {openai-completions: {…}}`). A hand-declared route has exactly one `api`, so the nesting states what the route already said, and it breaks every profile written against the flat shape for nothing. + +**Accept an opaque passthrough dict.** The schema is also the shape a configuration surface renders and the declaration `verify-config-catalog` cross-checks, both of which an unstructured dict defeats; it would also let a responses-only field land on a completions model, which per-field applicability exists to refuse. + +**Warn instead of refusing an unknown key.** That is the posture that hid this defect for the life of the surface: an accepted write and an unchanged failure teaches the operator that the switch does not work, not that the name is wrong. + +**Suggest a near spelling on an unknown key.** No repository utility computes edit distance, and adding a dependency or hand-rolling one under the per-file coverage gate is disproportionate for a diagnostic. Naming the offered fields answers the same question deterministically: the vocabulary check runs before any protocol resolves, so it names the whole offered set, while the per-protocol refusal narrows to what that protocol takes. + +## Consequences + +- An OpenAI-compatible gateway that rejects the `developer` role, `max_completion_tokens`, `store`, `stream_options`, or `strict` is now configuration rather than an unreachable provider, and the same holds for an Anthropic-compatible gateway rejecting `temperature` or tool `cache_control`. +- A pi-ai upgrade that adds a compat field fails the build until someone classifies it, which is how `chatTemplateKwargs` and the `chat-template` formats stopped being a standing exception. +- Unknown compat keys join every other configuration error's failure model. The improvement over the previous silent drop is bounded by the settings seam: an external file edit still keeps its last good value and warns, so the operator's signal is a restart rather than the write. +- **Deferred, not closed:** `publish()` reports a rejected stored section only through `ctx.logger.warn`, with no user-visible channel. It affects every settings namespace and is owned by `dsh-settings`. +- [[2026-08-08-pi-ai-per-model-reasoning-declarations]] is partially superseded: its compat-scope statements are restated here, while its `reasoningEfforts` shape, the alternatives that shape beat, and `modelOverrides` remain the current authority. diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md new file mode 100644 index 0000000000..c592044e23 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md @@ -0,0 +1,51 @@ +# Agent Note: pi-ai Wire-Compatibility Surface in llm-pi-ai + +Status: implemented + +[English](2026-08-18-pi-ai-wire-compat-surface.md) | 中文 + +## Problem + +pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提示词由哪个角色承载、输出上限写在哪个字段、是否发出 `store` 与 `stream_options`、工具定义是否携带 `strict`。对于其检测无法识别的端点,答案是「这就是 OpenAI 本身」:`detectCompat` 返回 `supportsDeveloperRole: true`、`maxTokensField: "max_completion_tokens"`、`supportsStore: true`。而手工声明的路由按其构造就是 pi-ai 未随附的端点,于是每一条这样的路由都收到了 OpenAI 自己的请求形状。 + +适配器只开放了 pi-ai 三十个 compat 字段中的两个([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 把它们限定为「pi-ai 推理分派读取的那些开关」),而 `supportsDeveloperRole` 恰恰落在该作用域之内却不在其中:它的发送点是 `model.reasoning && compat.supportsDeveloperRole`。因此一个声明了 `reasoningEfforts` 的手工声明模型会把系统提示词以 `role: "developer"` 发出——多数 OpenAI 兼容网关会拒绝该角色——而没有任何配置能够更正,该网关根本接不进来。 + +硬写这个字段比不支持更糟。schemastery 会放行未知键,而解析只读取两个名字,于是 `compat: {supportsDeveloperRole: false}` 通过校验、落盘,随后被丢弃:运维看到的是一次被接受的写入和一个毫无变化的故障。`maxTokensField` 带着同一缺陷、却有更大的波及面,因为它塑造每一个请求,而不只是推理模型的请求。 + +## Decision + +三张漂移门禁——每个 pi-ai compat 类型一张,以 `Record` 为键——把全部三十个上游字段分类为 `offer` 或 `withhold`,其中二十个开放。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。 + +`PiAiCompatProfile` 保持为带逐字段 JSDoc 的显式 interface——它是配置界面所渲染、也是 `docs/config-catalog.md` 所粘贴的东西——并由一个作用在对称差上的类型级 `AssertNever` 证明它恰好命名了开放集。schemastery schema 声明为 `z`,于是四个面互锁:上游新增字段、门禁漏一条、interface 忘记一个字段、schema 少一个键,都会在编译期以点名该字段的方式失败。 + +协议适用性逐字段判断,而非整块判断。`supportsDeveloperRole` 在 pi-ai 声明它的任何地方均可设置(`openai-completions` 与 `openai-responses`),`thinkingFormat` 只在前者,`supportsTemperature` 只在 `anthropic-messages`。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。`chatTemplateKwargs` 予以开放,这正是两个 `chat-template` 思考格式得以命名的前提。 + +没有任何协议声明的 `compat` 键,以及被门禁扣留的键,都在其被写下之处遭到拒绝而非丢弃。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,`chatTemplateKwargs` 都出现在每一个解析过的 profile 上。 + +## Where a refusal lands + +所有检查都在 `resolveProfiles` 中运行,而请求路径不会重新进入它:适配器按原始快照的标识 memoize,且 `apply` 会主动预先解析一次。因此一次拒绝会以 `settings-rejected` 的形式在落盘之前抵达 `settings.mutate`,以插件挂载失败的形式抵达 `cordis.yml` 的 `config:` 块,以 `settings.register` 启动失败的形式抵达已存的 section。 + +对 settings 文件的外部编辑是唯一无法报告的路径:提供方监听器调用 `publish()`,它捕获失败的 section、记录 `settings: keeping last good "%s"`,并让该 namespace 继续服务其先前的值。这是 settings seam 对每一种 schema 与校验器失败的既有行为,并非本次开放引入,弥合它属于那个 seam 而不属于此处。对 compat 而言改变的是失败模型而非报告方式:一个从前永远静默无效的键,如今会拦下下一次启动。 + +## Alternatives considered + +**只补 `supportsDeveloperRole`。** 它修好了报告中的那个网关,却放任 `maxTokensField`——它塑造每一个请求,而不只是推理模型的请求——继续拖垮一整类端点,而且下一个上游新增字段依然可以静默落后。 + +**开放全部上游字段。** pi-ai 自己的 custom-provider 文档收敛到一个小得多的集合,其旗舰示例只点名六个,其余都是其 catalog 已经设定好的厂商绑定开关。在手工声明路由上暴露 `zaiToolStream` 或 `vercelGatewayRouting`,等于提供一个「正确用法是别做手工声明路由」的旋钮。 + +**把 `compat` 按协议分层**(`compat: {openai-completions: {…}}`)。手工声明路由恰好只有一个 `api`,因此这层嵌套只是复述路由已经说过的事,还白白破坏了所有按扁平形状写下的 profile。 + +**接受一个不透明的透传 dict。** 该 schema 同时是配置界面渲染的形状、也是 `verify-config-catalog` 交叉校验的声明,无结构的 dict 会同时击溃两者;它还会让 responses 独有的字段落到 completions 模型上,而逐字段适用性正是为拒绝这种情况而存在。 + +**未知键只告警不拒绝。** 这恰恰是让本缺陷伴随该面存活至今的姿态:一次被接受的写入加一个毫无变化的故障,教给运维的是「这个开关没用」,而不是「这个名字写错了」。 + +**为未知键给出近似拼写建议。** 仓库中没有计算编辑距离的工具,在逐文件覆盖率门禁之下为一条诊断引入依赖或手搓一个都不成比例。点名开放字段能确定地回答同一个问题:词汇检查跑在任何协议解析之前,因此它列出整个开放集,而按协议的拒绝则收窄到该协议实际接受的字段。 + +## Consequences + +- 拒绝 `developer` 角色、`max_completion_tokens`、`store`、`stream_options` 或 `strict` 的 OpenAI 兼容网关,如今属于配置问题而非无法接入的提供方;拒绝 `temperature` 或工具 `cache_control` 的 Anthropic 兼容网关同理。 +- pi-ai 升级新增 compat 字段会使构建失败,直到有人为它做出分类——`chatTemplateKwargs` 与那两个 `chat-template` 格式正是因此不再是一项长期例外。 +- 未知 compat 键并入了其余所有配置错误的失败模型。相对此前静默丢弃的改善程度受 settings seam 限制:外部文件编辑仍会保留其上一个有效值并告警,因此运维拿到的信号是一次重启,而不是那次写入。 +- **搁置而非解决:** `publish()` 对被拒绝的已存 section 只通过 `ctx.logger.warn` 报告,没有面向用户的通道。它影响每一个 settings namespace,归属 `dsh-settings`。 +- [[2026-08-08-pi-ai-per-model-reasoning-declarations]] 被部分取代:其 compat 作用域的陈述在此重述,而其 `reasoningEfforts` 形状、该形状所击败的备选方案以及 `modelOverrides` 仍是当前权威。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4fdaf689b8..1f371748b7 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: 9e3dc2b14a04877c191227703752d0b1dbb2b92b -config-catalog.zh.md: 56c1fd075b680c79c7730b9bdcb1275e83bb3f64 +config-catalog.md: ad9bb6284354b934bd6e0bf3235dcd0b74f33cc5 +config-catalog.zh.md: 7038b951a14115873a032fab0066be2a990e3a3f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9e3dc2b14a..ad9bb62843 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -938,10 +938,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -1022,7 +1023,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -1036,19 +1037,65 @@ export interface PiAiModelProfile { export type PiAiModelOverride = Omit /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * 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 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. + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** 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. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions`, `openai-responses`. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: 'max_completion_tokens' | 'max_tokens' + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** Kwargs sent as `chat_template_kwargs`, for the two `chat-template` thinking formats; `openai-completions`. */ + chatTemplateKwargs?: Record + /** Whether the endpoint accepts `strict` in tool definitions; `openai-completions`, `openai-responses`. */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: 'anthropic' + /** Whether the endpoint accepts long prompt-cache retention; all three protocols. */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean } /** One request modality a pi-ai model may accept. */ @@ -1065,21 +1112,12 @@ export type PiAiModality = Model['input'][number] export type PiAiReasoningEfforts = Partial> /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude - -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' +export type PiAiThinkingFormat = NonNullable ``` -Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) +Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `ChatTemplateKwargValue` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:172`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:173`](../packages/llm/llm-pi-ai/src/config.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 56c1fd075b..7038b951a1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -940,10 +940,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -1024,7 +1025,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -1038,19 +1039,65 @@ export interface PiAiModelProfile { export type PiAiModelOverride = Omit /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * 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 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. + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** 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. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions`, `openai-responses`. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: 'max_completion_tokens' | 'max_tokens' + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** Kwargs sent as `chat_template_kwargs`, for the two `chat-template` thinking formats; `openai-completions`. */ + chatTemplateKwargs?: Record + /** Whether the endpoint accepts `strict` in tool definitions; `openai-completions`, `openai-responses`. */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: 'anthropic' + /** Whether the endpoint accepts long prompt-cache retention; all three protocols. */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean } /** One request modality a pi-ai model may accept. */ @@ -1067,21 +1114,12 @@ export type PiAiModality = Model['input'][number] export type PiAiReasoningEfforts = Partial> /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude - -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' +export type PiAiThinkingFormat = NonNullable ``` -依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) +依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `ChatTemplateKwargValue`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) -来源:[`packages/llm/llm-pi-ai/src/config.ts:172`](../packages/llm/llm-pi-ai/src/config.ts) +来源:[`packages/llm/llm-pi-ai/src/config.ts:173`](../packages/llm/llm-pi-ai/src/config.ts) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 553b7d4557..c4f1dd2fa0 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: d775e72616822ce0deee063ac0f3fc453af1a126 -README.zh.md: 621d67d1c181c6d4c78ea0078f521acccce92653 +README.md: 3251cc7e71e343fa82ab76f95fe341949a0836c6 +README.zh.md: b62a4069e9f5c57533054d439fd63b9cbd6f44cd diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index d775e72616..3251cc7e71 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -51,9 +51,12 @@ Configure credentials, the model catalog, and deployment-specific transport sett apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 - # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + # Request shape for an endpoint whose URL pi-ai cannot recognize; it + # would otherwise be addressed as though it were OpenAI itself. compat: thinkingFormat: deepseek + supportsDeveloperRole: false + maxTokensField: max_tokens models: - id: acme-large name: Acme Large @@ -85,9 +88,11 @@ A profile's `models` list *replaces* the route's installed catalog rather than e The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, selectors offer no Off and an explicit Off request is refused — a request naming no effort still goes out without the parameter, so what the provider then does is its own default; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep. -### Reasoning-dispatch compat switches +### Wire-compatibility switches -How a thinking level travels — `reasoning_effort` alone, DeepSeek's `thinking: {type}` plus effort, z.ai's `thinking` object, and so on — is pi-ai's `compat.thinkingFormat`, which pi-ai guesses from the endpoint URL; a private gateway's URL says nothing, so a DeepSeek-dialect gateway would be spoken to in the OpenAI dialect with no way to correct it. `compat.thinkingFormat` and `compat.supportsReasoningEffort` are therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's URL-derived guess; setting a route-level switch shadows the catalog entry's value for every model on the route, and there is no spelling for handing a field back to the catalog short of restating its value. `thinkingFormat` accepts pi-ai's dispatchable formats except the two `chat-template` variants, which need `chatTemplateKwargs` this configuration does not expose. Both switches exist only on `openai-completions` — the other protocols carry their reasoning shape in the protocol itself — so a model-level switch elsewhere fails resolution, a route-level one skips models of other protocols, and a route with no `openai-completions` model at all is refused. The rest of pi-ai's compat surface (`supportsStore`, `maxTokensField`, …) stays auto-detected and is deliberately not configurable here. +pi-ai shapes each request from the provider id and baseURL: which role carries the system prompt, which field caps output, how a thinking level travels. A private gateway's URL says nothing, and for an endpoint pi-ai does not recognize the detection answers as though it were OpenAI itself — a reasoning model's system prompt goes out as `developer`, the output cap as `max_completion_tokens`, the thinking level as a bare `reasoning_effort` — and most OpenAI-compatible gateways reject at least one of those. `compat` is therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's own detection; a route-level switch shadows the catalog entry's value for every model that reads it, and there is no spelling for handing a field back to the catalog short of restating its value. + +Each switch belongs to the protocols whose pi-ai compat type declares it: `supportsDeveloperRole` is settable on an `openai-completions` or `openai-responses` route, `thinkingFormat` only on the former, `supportsTemperature` only on `anthropic-messages`. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it at all. Two kinds of key are refused rather than dropped: one no protocol declares (a misspelling), and one pi-ai's installed catalog owns for a named vendor (`openRouterRouting`, `zaiToolStream`, `deferredToolsMode`, `sessionAffinityFormat`, `supportsOpenAIGrammarTools`, `supportsToolSearch`, `supportsExplicitPromptCacheMode`, `supportsToolReferences`, `vercelGatewayRouting`, `sendSessionAffinityHeaders`) — a route needing a vendor's own switch is a catalog route that should be named as such. The offered set is pinned to pi-ai's three compat types by drift gates, so an upgrade adding a field fails the build until someone classifies it. A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 621d67d1c1..b62a4069e9 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -51,9 +51,12 @@ apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 - # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + # Request shape for an endpoint whose URL pi-ai cannot recognize; it + # would otherwise be addressed as though it were OpenAI itself. compat: thinkingFormat: deepseek + supportsDeveloperRole: false + maxTokensField: max_tokens models: - id: acme-large name: Acme Large @@ -85,9 +88,11 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,选择器不提供 Off,显式请求 Off 会被拒绝——不点名任何档位的请求仍会在不带该参数的情况下发出,提供方随后做什么是它自己的默认行为;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。 -### 推理分派的 compat 开关 +### 协议兼容开关 -思考级别如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加上档位、z.ai 的 `thinking` 对象,诸如此类——就是 pi-ai 的 `compat.thinkingFormat`,pi-ai 会从端点 URL 猜测它;私有网关的 URL 什么也说明不了,于是说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且无从更正。因此 `compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测;设置路由级开关会为路由上的每个模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。`thinkingFormat` 接受 pi-ai 可分派的各种格式,但不含两个 `chat-template` 变体:它们需要的 `chatTemplateKwargs` 本配置并不暴露。两个开关都只存在于 `openai-completions` 上——其余协议的推理形状由协议本身承载——因此在其他协议的模型上设置模型级开关会使解析失败,路由级开关会跳过其他协议的模型,而完全没有 `openai-completions` 模型的路由则会被拒绝。pi-ai compat 面的其余部分(`supportsStore`、`maxTokensField`……)保持自动检测,特意不在此处开放配置。 +pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示词由哪个角色承载、输出上限写在哪个字段、思考级别如何传输。私有网关的 URL 什么也说明不了,而对于 pi-ai 无法识别的端点,其检测会当作 OpenAI 本身来回答——推理模型的系统提示词以 `developer` 发出、输出上限写作 `max_completion_tokens`、思考级别只发一个裸的 `reasoning_effort`——而多数 OpenAI 兼容网关至少会拒绝其中之一。因此 `compat` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 自身的检测;路由级开关会为每个读取它的模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。 + +每个开关归属于其 pi-ai compat 类型声明了它的那些协议:`supportsDeveloperRole` 可设在 `openai-completions` 或 `openai-responses` 路由上,`thinkingFormat` 只能设在前者,`supportsTemperature` 只能设在 `anthropic-messages` 上。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。两类键会被拒绝而非丢弃:没有任何协议声明的键(笔误),以及 pi-ai 已安装 catalog 为具名厂商掌管的键(`openRouterRouting`、`zaiToolStream`、`deferredToolsMode`、`sessionAffinityFormat`、`supportsOpenAIGrammarTools`、`supportsToolSearch`、`supportsExplicitPromptCacheMode`、`supportsToolReferences`、`vercelGatewayRouting`、`sendSessionAffinityHeaders`)——需要某厂商专属开关的路由,本就是一条应当以该厂商命名的 catalog 路由。开放集由漂移门禁钉在 pi-ai 的三个 compat 类型上,因此上游新增字段会使构建失败,直到有人为它做出分类。 条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成单次请求上限。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 7a4ff7e8a2..5a42ac9220 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -15,11 +15,14 @@ import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' import type { + AnthropicMessagesCompat, Api, + ChatTemplateKwargValue, Model, ModelCost, ModelThinkingLevel, OpenAICompletionsCompat, + OpenAIResponsesCompat, Provider, ThinkingLevelMap, } from '@earendil-works/pi-ai' @@ -79,23 +82,16 @@ const THINKING_LEVEL_GATE: Record = { /** Every pi-ai thinking level a profile may declare, in escalation order. */ export const THINKING_LEVELS = Object.keys(THINKING_LEVEL_GATE) as readonly ModelThinkingLevel[] -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' - /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude +export type PiAiThinkingFormat = NonNullable /** * The nameable reasoning-dispatch formats, most-reached first. The `Record` * key type is a drift gate: a pi-ai upgrade that adds a format (0.84 added - * `baseten`) fails compilation here until the format is classified as offered - * here or withheld above, so the offer never silently lags the upstream set. + * `baseten`) fails compilation here until the new format is named, so the + * offer never silently lags the upstream set. The two `chat-template` variants + * are nameable because {@link PiAiCompatProfile.chatTemplateKwargs} carries + * the kwargs they dispatch through. */ const THINKING_FORMAT_GATE: Record = { 'openai': true, @@ -104,6 +100,8 @@ const THINKING_FORMAT_GATE: Record = { 'together': true, 'zai': true, 'qwen': true, + 'chat-template': true, + 'qwen-chat-template': true, 'string-thinking': true, 'ant-ling': true, } @@ -183,19 +181,251 @@ export function catalogModels(provider: string): Map> { export type PiAiReasoningEfforts = Partial> /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * 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 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. + * Whether one pi-ai compat field is configurable on a profile. + * + * `withhold` is the disposition for a field pi-ai's installed catalog already + * sets for a named vendor. Reaching for one of those on a hand-declared route + * means configuring a provider that should have been named as a catalog route + * instead, where the installed entry carries the right value already. + */ +type CompatDisposition = 'offer' | 'withhold' + +/** + * Disposition of every `OpenAICompletionsCompat` field. The `Record` key type + * is a drift gate: a pi-ai upgrade that adds a field fails compilation here + * until it is classified, so the offer never silently lags the upstream set. + */ +const COMPLETIONS_COMPAT_GATE = { + supportsStore: 'offer', + supportsDeveloperRole: 'offer', + supportsReasoningEffort: 'offer', + supportsUsageInStreaming: 'offer', + maxTokensField: 'offer', + requiresToolResultName: 'offer', + requiresAssistantAfterToolResult: 'offer', + requiresThinkingAsText: 'offer', + requiresReasoningContentOnAssistantMessages: 'offer', + thinkingFormat: 'offer', + chatTemplateKwargs: 'offer', + supportsStrictMode: 'offer', + cacheControlFormat: 'offer', + supportsLongCacheRetention: 'offer', + openRouterRouting: 'withhold', + vercelGatewayRouting: 'withhold', + zaiToolStream: 'withhold', + supportsOpenAIGrammarTools: 'withhold', + sendSessionAffinityHeaders: 'withhold', + deferredToolsMode: 'withhold', + sessionAffinityFormat: 'withhold', +} as const satisfies Record + +/** Disposition of every `OpenAIResponsesCompat` field; a drift gate like the one above. */ +const RESPONSES_COMPAT_GATE = { + supportsDeveloperRole: 'offer', + supportsStrictMode: 'offer', + supportsLongCacheRetention: 'offer', + sessionAffinityFormat: 'withhold', + supportsOpenAIGrammarTools: 'withhold', + supportsToolSearch: 'withhold', + supportsExplicitPromptCacheMode: 'withhold', +} as const satisfies Record + +/** Disposition of every `AnthropicMessagesCompat` field; a drift gate like the one above. */ +const ANTHROPIC_COMPAT_GATE = { + supportsEagerToolInputStreaming: 'offer', + supportsLongCacheRetention: 'offer', + supportsCacheControlOnTools: 'offer', + supportsTemperature: 'offer', + forceAdaptiveThinking: 'offer', + allowEmptySignature: 'offer', + supportsStrictTools: 'offer', + sendSessionAffinityHeaders: 'withhold', + supportsToolReferences: 'withhold', +} as const satisfies Record + +/** + * The compat gate of every wire protocol a profile may configure, in the + * protocol table's order. A protocol absent here takes no configured compat, + * which is why an unrecognized `api` refuses every switch rather than + * silently dropping it. + */ +const COMPAT_GATES: Readonly>>> = { + 'openai-completions': COMPLETIONS_COMPAT_GATE, + 'openai-responses': RESPONSES_COMPAT_GATE, + 'anthropic-messages': ANTHROPIC_COMPAT_GATE, +} + +/** The field names one gate offers. */ +type OfferedIn = { [K in keyof G]: G[K] extends 'offer' ? K : never }[keyof G] + +/** Every compat field name a profile may set, on whichever protocol takes it. */ +type OfferedCompatField = + | OfferedIn + | OfferedIn + | OfferedIn + +/** + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** 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. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions`, `openai-responses`. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: 'max_completion_tokens' | 'max_tokens' + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** Kwargs sent as `chat_template_kwargs`, for the two `chat-template` thinking formats; `openai-completions`. */ + chatTemplateKwargs?: Record + /** Whether the endpoint accepts `strict` in tool definitions; `openai-completions`, `openai-responses`. */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: 'anthropic' + /** Whether the endpoint accepts long prompt-cache retention; all three protocols. */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean +} + +/** Compile-time constraint that `T` is `never`. */ +type AssertNever = T + +/** + * Proof that every documented field is one a gate offers. A field the profile + * declares past the gates fails compilation with its own name in the error. + */ +export type EveryProfileFieldIsOffered = AssertNever> + +/** + * Proof that every offered field is documented. A gate entry flipped to + * `offer` without a profile field fails compilation with its own name in the + * error, which is the half a schema alone cannot catch. + */ +export type EveryOfferedFieldIsDocumented = AssertNever> + +/** + * The compat entries a profile actually set. + * + * schemastery materializes an absent dict as `{}` — the behavior + * `reasoningEfforts` works around with a union — so every parsed profile + * carries a `chatTemplateKwargs` key whether or not anyone wrote one. An empty + * one states nothing here: it would send no kwargs, which is exactly what + * leaving the field out does, so absent and empty are the same request and + * neither may make a route look like it configured a switch. + * @param compat - the configured switches, when any. + * @returns the entries carrying a value, in declaration order. + */ +function configuredCompatEntries(compat: PiAiCompatProfile | undefined): readonly (readonly [string, unknown])[] { + return Object.entries(compat ?? {}).flatMap(([field, value]) => { + if (value === undefined) return [] + const empty = typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.keys(value as object).length === 0 + return empty ? [] : [[field, value] as const] + }) +} + +/** + * The protocols offering one compat field, in {@link COMPAT_GATES} order. + * @param field - configured compat field name. + * @returns the protocols whose compat takes it; empty when none does, which + * is either a withheld field or a name no upstream compat type declares. + */ +function compatProtocols(field: string): readonly string[] { + return Object.entries(COMPAT_GATES).flatMap(([api, gate]) => gate[field] === 'offer' ? [api] : []) +} + +/** + * The compat fields one protocol offers, for a diagnostic that has to show + * what was available instead of the name that missed. + * @param api - wire protocol. + * @returns the offered field names, or an empty list for a protocol taking no compat. + */ +function offeredCompatFields(api: string): readonly string[] { + return Object.entries(COMPAT_GATES[api] ?? {}).flatMap(([field, disposition]) => disposition === 'offer' ? [field] : []) +} + +/** + * Every offered field name, deduplicated, for the one diagnostic that cannot + * narrow by protocol: the vocabulary check runs before any protocol resolves, + * which is what lets it refuse a misspelling on a route whose models would + * never have reached the protocol that declares the intended field. + * @returns the offered field names across every protocol, in gate order. + */ +function allOfferedCompatFields(): readonly string[] { + const fields = new Set() + for (const api of Object.keys(COMPAT_GATES)) { + for (const field of offeredCompatFields(api)) fields.add(field) + } + return [...fields] +} + +/** + * Reject a compat key no protocol offers. Runs before any protocol is + * resolved, so a withheld field or a misspelling fails even on a route whose + * models never reach the protocol that would have taken it — the alternative + * being the silent drop that let an unreadable switch look applied. + * @param provider - provider route key, for diagnostics. + * @param site - the configuration site, for diagnostics. + * @param compat - the configured switches, when any. + * @throws Error naming the offending key. + */ +function assertOfferedCompatFields( + provider: string, + site: string, + compat: PiAiCompatProfile | undefined, +): void { + // Every key, not only the ones carrying a value: a withheld or undeclared + // name is never in the schema, so schemastery cannot have materialized it — + // whatever its value, a person wrote it and expects it to do something. + for (const field of Object.keys(compat ?? {})) { + if (compatProtocols(field).length > 0) continue + const declared = Object.values(COMPAT_GATES).some(gate => gate[field] !== undefined) + if (declared) { + invalid(provider, `${site} sets compat "${field}", which is not configurable here: pi-ai's installed` + + ' catalog sets it for the vendors that need it, so name that provider as the route instead') + } + invalid(provider, `${site} sets compat "${field}", which no wire protocol declares; the configurable` + + ` switches are ${allOfferedCompatFields().join(', ')}`) + } } /** One configured model entry: an id plus the catalog fields it overrides. */ @@ -233,7 +463,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -258,7 +488,7 @@ export interface RouteCatalogRequest { models?: readonly PiAiModelProfile[] /** Installed-catalog customizations by model id; only meaningful while `models` is absent. */ modelOverrides?: Readonly> - /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */ + /** Route-level wire-compatibility switches, landing on each model whose protocol declares them; entries override per field. */ compat?: PiAiCompatProfile /** Context capacity for a model neither the entry nor the catalog sizes. */ defaultContextWindow: number @@ -368,16 +598,20 @@ function resolveModelReasoning( return { reasoning: true, thinkingLevelMap: map } } +/** The compat block a materialized model carries, whichever protocol it speaks. */ +type ModelCompat = OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat + /** - * Resolve one model's compat block from the profile's reasoning switches. + * Resolve one model's compat block from the profile's switches. * - * A model switch wins over the route switch; whatever neither sets keeps the - * installed entry's value, and a field no layer decides falls through to - * pi-ai's baseURL-derived detection. Only an `openai-completions` model takes - * the switches at all: a model-level switch on any other protocol fails - * resolution, while a route-level default skips past such models — the same - * posture as the route-level `reasoning` default, which also must not fail - * models it does not fit. + * A model switch wins over the route switch field by field; whatever neither + * sets keeps the installed entry's value, and a field no layer decides falls + * through to pi-ai's own detection. A model-level switch its protocol does not + * take fails resolution — about one named model it can only be a mistake — + * while a route-level one skips past such models, since a route default must + * stay settable on a route whose models do not all speak one protocol. Every + * field reaching here is offered by some protocol; {@link + * assertOfferedCompatFields} has already refused the rest. * @param provider - provider route key, for diagnostics. * @param entry - the configured model entry. * @param route - the route-level switches, when any. @@ -391,31 +625,31 @@ function resolveModelCompat( route: PiAiCompatProfile | undefined, base: Model | undefined, api: string, -): { compat: OpenAICompletionsCompat } | Record { - const thinkingFormat = entry.compat?.thinkingFormat ?? route?.thinkingFormat - const supportsReasoningEffort = entry.compat?.supportsReasoningEffort ?? route?.supportsReasoningEffort - if (thinkingFormat === undefined && supportsReasoningEffort === undefined) return {} - if (api !== 'openai-completions') { - if (entry.compat?.thinkingFormat !== undefined || entry.compat?.supportsReasoningEffort !== undefined) { - invalid(provider, `model "${entry.id}" sets compat reasoning switches, but its api is "${api}";` - + ' thinkingFormat and supportsReasoningEffort exist only on openai-completions') - } - return {} +): { compat: ModelCompat } | Record { + const gate = COMPAT_GATES[api] + const configured: Record = {} + for (const [field, value] of configuredCompatEntries(route)) { + if (gate?.[field] !== 'offer') continue + configured[field] = value } + for (const [field, value] of configuredCompatEntries(entry.compat)) { + if (gate?.[field] !== 'offer') { + const offered = offeredCompatFields(api) + invalid(provider, `model "${entry.id}" sets compat "${field}", but its api is "${api}", which does not` + + ` take it; that switch exists on ${compatProtocols(field).join(', ')}, and "${api}" offers` + + ` ${offered.length === 0 ? 'no configurable compat' : offered.join(', ')}`) + } + configured[field] = value + } + if (Object.keys(configured).length === 0) return {} // The installed entry's compat matches the entry's OWN api — a route-level // `api` repoint (an anthropic catalog served through an OpenAI-compatible // gateway) leaves `base.compat` in the other protocol's shape, so it is // inherited only while the resolved api still is the entry's. A repointed // model starts from pi-ai's baseURL-derived detection instead, which is // what a protocol change means for every other compat field too. - const inherited: OpenAICompletionsCompat | undefined = base?.api === api ? base.compat : undefined - return { - compat: { - ...inherited, - ...thinkingFormat === undefined ? {} : { thinkingFormat }, - ...supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort }, - }, - } + const inherited = base?.api === api ? base.compat : undefined + return { compat: { ...inherited, ...configured } as ModelCompat } } /** One route's materialized catalog, plus the request caps its profile chose. */ @@ -485,8 +719,13 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { + ' must be listed in configuration') } const routeApi = sharedCatalogApi(defaults) - const routeCompatDefined = request.compat?.thinkingFormat !== undefined - || request.compat?.supportsReasoningEffort !== undefined + // Vocabulary before protocols: a withheld or undeclared switch is refused + // wherever it is written, so it cannot look applied on a route whose models + // never reach the protocol that would have taken it. + assertOfferedCompatFields(provider, 'route', request.compat) + for (const entry of entries) { + assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat) + } const seen = new Set() const configuredMaxTokens = new Map() const models = entries.map((entry) => { @@ -538,9 +777,15 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { ...resolveModelCompat(provider, entry, request.compat, base, api), } }) - if (routeCompatDefined && !models.some(model => model.api === 'openai-completions')) { - invalid(provider, 'sets compat reasoning switches, but no model on the route speaks openai-completions;' - + ' thinkingFormat and supportsReasoningEffort exist only on that protocol') + // Per field, not per block: a route may default a switch its completions + // models take beside one only its anthropic models do, and neither should + // fail for the other's sake. What is refused is a route default no model on + // the route could ever read, which is a route that will not behave as written. + for (const [field] of configuredCompatEntries(request.compat)) { + const takers = compatProtocols(field) + if (models.some(model => takers.includes(model.api))) continue + invalid(provider, `sets compat "${field}", but no model on the route speaks a protocol that takes it;` + + ` it exists on ${takers.join(', ')}`) } return { models, configuredMaxTokens } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 4e8e032df0..e5443b6a8f 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -14,7 +14,7 @@ * @module dsh-llm-pi-ai/config */ -import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' +import type { CacheRetention, ChatTemplateKwargValue, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from '@deepseek-ai/schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' @@ -91,10 +91,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -185,9 +186,43 @@ const thinkingBudgets = z.object({ high: z.number(), }) +/** + * One `chat_template_kwargs` value. The `$var` member is pi-ai's placeholder + * for a value dispatch fills from the request's thinking state, which is what + * makes a chat-template gateway configurable without restating its template. + */ +const chatTemplateKwarg: z = z.union([ + z.string(), + z.number(), + z.boolean(), + z.const(null), + z.object({ + $var: z.union(['thinking.enabled', 'thinking.effort'] as const).required(), + omitWhenOff: z.boolean(), + }), +]) + const compatProfile: z = z.object({ - thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), + supportsStore: z.boolean(), + supportsDeveloperRole: z.boolean(), supportsReasoningEffort: z.boolean(), + supportsUsageInStreaming: z.boolean(), + maxTokensField: z.union(['max_completion_tokens', 'max_tokens'] as const), + requiresToolResultName: z.boolean(), + requiresAssistantAfterToolResult: z.boolean(), + requiresThinkingAsText: z.boolean(), + requiresReasoningContentOnAssistantMessages: z.boolean(), + thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), + chatTemplateKwargs: z.dict(chatTemplateKwarg), + supportsStrictMode: z.boolean(), + cacheControlFormat: z.union(['anthropic'] as const), + supportsLongCacheRetention: z.boolean(), + supportsEagerToolInputStreaming: z.boolean(), + supportsCacheControlOnTools: z.boolean(), + supportsTemperature: z.boolean(), + forceAdaptiveThinking: z.boolean(), + allowEmptySignature: z.boolean(), + supportsStrictTools: z.boolean(), }) /** diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 51ce347e9a..5e02646f64 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -596,6 +596,46 @@ describe('provider profile lifecycle', () => { expect(server.requests[1]).not.toHaveProperty('reasoning_effort') }) + it('keeps the system role on a declared route whose gateway rejects the developer one', async () => { + vi.stubEnv('PI_TEST_KEY', 'test-key') + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'PI_TEST_KEY', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [ + // pi-ai sends the system prompt as `developer` to a reasoning + // model whenever its URL detection says the endpoint is OpenAI — + // which is what an unrecognized private URL resolves to. Most + // OpenAI-compatible gateways reject that role. + { id: 'acme-think', reasoningEfforts: { off: null, high: 'high' }, compat: { supportsDeveloperRole: false } }, + { id: 'acme-guess', reasoningEfforts: { off: null, high: 'high' } }, + ], + }, + }, + }) + const roles = async (model: string): Promise => { + await assemble(ctx, { + provider: 'acme-gateway', + model, + reasoningEffort: ReasoningEffortId('high'), + system: 'you are a harness', + messages: [], + }) + const request = server.requests.at(-1) as { messages: { role: string }[] } + return request.messages.map(message => message.role) + } + + expect(await roles('acme-think')).toEqual(['system']) + // The switch is the only thing that changes it: the same route, same + // endpoint, same reasoning declaration still gets pi-ai's guess. + expect(await roles('acme-guess')).toEqual(['developer']) + }) + it('sends a declared off value as the effort parameter instead of omitting it', async () => { vi.stubEnv('PI_TEST_KEY', 'test-key') const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index eb4ba511d4..f6f80eb878 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -755,7 +755,7 @@ describe('modelOverrides', () => { }) }) -describe('reasoning-dispatch compat switches', () => { +describe('compat switches', () => { /** The materialized models of one route, keyed by id. */ function modelsOf(providers: Record, route: string): Map> { const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? [] @@ -813,18 +813,133 @@ describe('reasoning-dispatch compat switches', () => { expect(models.get(responses.id)?.compat).toEqual(responses.compat) }) - it('rejects a model-level switch on a protocol that has no such field', () => { + it('rejects a model-level switch on a protocol that has no such field, naming what it offers', () => { expect(() => resolveProfiles({ anthropic: { models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }], }, - })).toThrow(/exist only on openai-completions/) + })).toThrow(/its api is "anthropic-messages", which does not take it.*exists on openai-completions/s) }) it('rejects route switches no model on the route can take', () => { expect(() => resolveProfiles({ anthropic: { compat: { thinkingFormat: 'openai' } }, - })).toThrow(/no model on the route speaks openai-completions/) + })).toThrow(/no model on the route speaks a protocol that takes it/) + }) + + it('carries the developer-role switch onto a hand-declared reasoning model', () => { + // pi-ai reads this switch only for a reasoning model, and detects it from + // the endpoint URL — which for a private gateway answers as though it were + // OpenAI itself, so the route must be able to say otherwise. + const models = modelsOf({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: false, maxTokensField: 'max_tokens' }, + models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }], + }, + }, 'acme-gateway') + + expect(models.get('acme-think')?.compat).toEqual({ + supportsDeveloperRole: false, + maxTokensField: 'max_tokens', + }) + }) + + it('carries a switch both OpenAI protocols declare onto an openai-responses route', () => { + const models = modelsOf({ + 'acme-responses': { + api: 'openai-responses', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: false }, + models: [{ id: 'acme-r', reasoningEfforts: { off: null, high: 'high' } }], + }, + }, 'acme-responses') + + expect(models.get('acme-r')?.compat).toEqual({ supportsDeveloperRole: false }) + }) + + it('carries an anthropic-only switch onto an anthropic-messages route', () => { + const models = modelsOf({ + 'acme-claude': { + api: 'anthropic-messages', + baseURL: 'https://acme.test', + compat: { supportsTemperature: false, supportsCacheControlOnTools: false }, + models: [{ id: 'acme-opus' }], + }, + }, 'acme-claude') + + expect(models.get('acme-opus')?.compat).toEqual({ + supportsTemperature: false, + supportsCacheControlOnTools: false, + }) + }) + + it('lands each route switch only on the models whose protocol declares it', () => { + const catalog = getBuiltinModels('xai') as readonly Model[] + const completions = catalog.find(model => model.api === 'openai-completions') + const responses = catalog.find(model => model.api === 'openai-responses') + if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog') + + const models = modelsOf({ + xai: { + // Both protocols take the first switch; only completions takes the second. + compat: { supportsDeveloperRole: false, thinkingFormat: 'openai' }, + models: [{ id: completions.id }, { id: responses.id }], + }, + }, 'xai') + + const onCompletions = models.get(completions.id)?.compat as OpenAICompletionsCompat + expect(onCompletions.supportsDeveloperRole).toBe(false) + expect(onCompletions.thinkingFormat).toBe('openai') + const onResponses = models.get(responses.id)?.compat as { supportsDeveloperRole?: boolean; thinkingFormat?: string } + expect(onResponses.supportsDeveloperRole).toBe(false) + expect(onResponses.thinkingFormat).toBeUndefined() + }) + + it('carries chat-template kwargs beside the thinking format that dispatches through them', () => { + const models = modelsOf({ + 'acme-qwen': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ + id: 'qwen-local', + reasoningEfforts: { off: null, medium: 'medium' }, + compat: { + thinkingFormat: 'qwen-chat-template', + chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } }, + }, + }], + }, + }, 'acme-qwen') + + expect(models.get('qwen-local')?.compat).toEqual({ + thinkingFormat: 'qwen-chat-template', + chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } }, + }) + }) + + it('refuses a compat key no wire protocol declares instead of dropping it', () => { + // The silent drop is what let an unreadable switch look applied: schemastery + // passes unknown keys through, and resolution used to read only two fields. + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDevelperRole: false } as never, + models: [{ id: 'acme-a' }], + }, + })).toThrow(/compat "supportsDevelperRole", which no wire protocol declares; the configurable switches are .*\bsupportsDeveloperRole\b/) + }) + + it('refuses a compat key pi-ai’s catalog owns, pointing at the catalog route', () => { + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'acme-a', compat: { openRouterRouting: {} } as never }], + }, + })).toThrow(/compat "openRouterRouting", which is not configurable here/) }) }) From 91e8d62b2af83cdca87a9d83b53b7665736a5c81 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:30:47 +0800 Subject: [PATCH 051/180] fix(subprocess): detect exited Windows terminals --- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess-local/src/windows-inspector.ts | 45 ++++++++++++++----- .../tests/windows-inspector.spec.ts | 14 ++++-- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 7af31d7aff..6b02932a92 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-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/subprocess/subprocess-local/README.md -README.md: 2c2046a886004321b4438da2af2723413e2454fe -README.zh.md: 26955caca951388a60b55c7afa806a933531e8e1 +README.md: 0935bb309bd10dec7503a74708a28442223bf296 +README.zh.md: e2e6c67e4dbe1890bcb5532594a62b650bfed85d diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 2c2046a886..0935bb309b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes start identities, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's absence through those identities because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. - **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 26955caca9..e2e6c67e4d 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -11,7 +11,7 @@ - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 启动身份,把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组),拆卸则通过这些身份验证 shell 已消失——因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 - **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。 diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index 9c306f595f..7280cbb9ee 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -1,8 +1,9 @@ /** * Windows process-table operations for terminal readiness, signalling, and * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time - * identity, the shell pid as a pseudo process group (Windows has no POSIX - * groups), and taskkill tree signalling. The koffi bindings load lazily so + * identity and process-handle wait-state liveness, the shell pid as a pseudo + * process group (Windows has no POSIX groups), and taskkill tree signalling. + * The koffi bindings load lazily so * non-Windows processes never touch Win32 libraries; all decision logic takes * an injectable internals boundary so suites can pin it on any host. * @module dsh-subprocess-local/windows-inspector @@ -19,12 +20,20 @@ export interface ProcessEntry { parentPid: number } +/** Creation identity plus the process object's current wait state. */ +export interface WindowsProcessState { + /** GetProcessTimes creation identity used to fence PID reuse. */ + started: string + /** Whether a zero-time process-handle wait reports the process still running. */ + active: boolean +} + /** Injectable Windows process operations used by one local PTY session. */ export interface WindowsProcessInspectorInternals { /** Enumerate the current process table (pid/parent pairs). */ snapshot(): ProcessEntry[] - /** Return one process's creation-time identity, or undefined when unreadable. */ - creationTime(pid: number): string | undefined + /** Return one process's creation identity and wait state, or undefined when unreadable. */ + processState(pid: number): WindowsProcessState | undefined /** Terminate one process tree; `force` maps to taskkill `/F`. */ taskkill(pid: number, force: boolean): void } @@ -89,7 +98,7 @@ export class WindowsProcessInspector implements ProcessInspector { } processTree(rootPid: number): ProcessIdentity[] { - return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.creationTime(pid)) + return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.processState(pid)?.started) } processSession(_sessionId: number): ProcessIdentity[] { @@ -97,8 +106,8 @@ export class WindowsProcessInspector implements ProcessInspector { } isAlive(identity: ProcessIdentity): boolean { - const started = this.internals.creationTime(identity.pid) - return started !== undefined && started === identity.started + const state = this.internals.processState(identity.pid) + return state?.active === true && state.started === identity.started } signalGroup(pgid: number, signal: SubprocessTerminalSignal): void { @@ -158,6 +167,7 @@ interface Win32Bindings { kernel: NativePtr, user: NativePtr, ): number + waitForSingleObject(handle: NativePtr, milliseconds: number): number closeHandle(handle: NativePtr): number } @@ -202,6 +212,9 @@ let cachedStructs: ReturnType | undefined const TH32CS_SNAPPROCESS = 0x2 const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +const SYNCHRONIZE = 0x00100000 +const WAIT_OBJECT_0 = 0 +const WAIT_TIMEOUT = 0x102 let cachedBindings: Win32Bindings | undefined @@ -230,6 +243,7 @@ function win32Bindings(): Win32Bindings { koffi.pointer(FILETIME), koffi.pointer(FILETIME), ]), + waitForSingleObject: bind('WaitForSingleObject', 'uint32', [PVOID, 'uint32']), closeHandle: bind('CloseHandle', 'int', [PVOID]), } as unknown as Win32Bindings return cachedBindings @@ -273,10 +287,10 @@ function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { return entries } -/** Read one process's creation-time identity through GetProcessTimes. */ -function windowsCreationTime(bindings: Win32Bindings, pid: number): string | undefined { +/** Read one process's creation identity and current wait state. */ +function windowsProcessState(bindings: Win32Bindings, pid: number): WindowsProcessState | undefined { const { FILETIME } = win32Structs() - const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) + const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid) if (isInvalidHandle(handle)) return undefined try { const creation = allocNative(FILETIME, 1) @@ -288,7 +302,14 @@ function windowsCreationTime(bindings: Win32Bindings, pid: number): string | und treats undefined as a detector miss. */ if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return undefined const record = koffi.decode(creation, FILETIME) as { dwLowDateTime: number; dwHighDateTime: number } - return `${record.dwHighDateTime}:${record.dwLowDateTime}` + const wait = bindings.waitForSingleObject(handle, 0) + /* v8 ignore next -- an opened process handle has exactly one of these two + zero-time wait states; an unexpected Win32 failure is an unreadable process. */ + if (wait !== WAIT_OBJECT_0 && wait !== WAIT_TIMEOUT) return undefined + return { + started: `${record.dwHighDateTime}:${record.dwLowDateTime}`, + active: wait === WAIT_TIMEOUT, + } } finally { bindings.closeHandle(handle) } @@ -298,7 +319,7 @@ function windowsCreationTime(bindings: Win32Bindings, pid: number): string | und function defaultWindowsProcessInternals(): WindowsProcessInspectorInternals { return { snapshot: () => snapshotWindowsProcesses(win32Bindings()), - creationTime: pid => windowsCreationTime(win32Bindings(), pid), + processState: pid => windowsProcessState(win32Bindings(), pid), taskkill: taskkillTree, } } diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts index 667c6cae46..e00bdeb9e2 100644 --- a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -9,21 +9,22 @@ import type { NativePtr, ProcessEntry, WindowsProcessInspectorInternals, + WindowsProcessState, } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' function fakeInternals() { const entries: ProcessEntry[] = [] - const times = new Map() + const states = new Map() const kills: Array<[number, boolean]> = [] return { internals: { snapshot: () => [...entries], - creationTime: pid => times.get(pid), + processState: pid => states.get(pid), taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) }, } satisfies WindowsProcessInspectorInternals, - add(entry: ProcessEntry, started?: string): void { + add(entry: ProcessEntry, started?: string, active = true): void { entries.push(entry) - if (started !== undefined) times.set(entry.pid, started) + if (started !== undefined) states.set(entry.pid, { started, active }) }, kills, } @@ -80,6 +81,9 @@ describe('WindowsProcessInspector (injected internals)', () => { expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true) expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false) expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false) + + fake.add({ pid: 12, parentPid: 10 }, 't12', false) + expect(inspector.isAlive({ pid: 12, started: 't12' })).toBe(false) }) it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => { @@ -94,8 +98,10 @@ describe('WindowsProcessInspector (injected internals)', () => { it('signals a process only while its start identity matches', () => { const fake = fakeInternals() fake.add({ pid: 10, parentPid: 0 }, 't10') + fake.add({ pid: 11, parentPid: 10 }, 't11', false) const inspector = new WindowsProcessInspector(fake.internals) inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL') + inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL') inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM') expect(fake.kills).toEqual([[10, true]]) }) From 2f759a6b6509c3e56be761692068ea62b0372fe2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:30:59 +0800 Subject: [PATCH 052/180] fix(shell): preserve prompt-like PowerShell output --- packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh-persistent/src/index.ts | 2 +- .../shell/tool-pwsh-persistent/tests/tools.spec.ts | 14 +++++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 84278ef05e..24384c240c 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index 5fc2d3a03d..f0a575363e 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -124,7 +124,7 @@ function commandOutput( // scrolled out and extraction fell back to the echoed copy. captured = captured.replaceAll(wrapper, '') return { - text: stripPrompt(captured.replace(/^\r?\n/, '')), + text: captured.replace(/^\r?\n/, '').replace(/\r?\n$/, ''), incomplete: startMarker < 0, exitCode: Number(status), } diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index 6a856b1ff8..8d76abd3a6 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -100,6 +100,7 @@ type StubMode = | 'paged-scrollback' | 'with-echo' | 'exit-after-send' + | 'prompt-collision' const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ @@ -215,7 +216,9 @@ class StubTerminalSession implements TerminalBackendSession { } const commandOutput = this.mode === 'large' ? 'x'.repeat(100) - : this.mode === 'nonzero' ? '' : 'hello from stub' + : this.mode === 'nonzero' ? '' + : this.mode === 'prompt-collision' ? this.motd + : 'hello from stub' const exitCode = this.mode === 'nonzero' ? 7 : 0 const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` this.scrollback += output @@ -362,6 +365,15 @@ describe('tool-pwsh-persistent', () => { expect(result).not.toContain('Invoke-Expression') }) + it('preserves command output that equals the private shell prompt', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'prompt-collision' + expect(text(await call(ctx, owner, 'complete prompt collision'))).toBe(session.motd) + }) + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub' }) await call(ctx, owner, 'warm up') From cef99b17d47fdda90921da698cdc8d35e497e2b3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:31:22 +0800 Subject: [PATCH 053/180] test(acp): snapshot persistent PowerShell tool --- examples/acp-agent/tests/acp.snapshot.ts | 10 +++++ .../tests/persistent-pwsh.cordis.snapshot.yml | 45 +++++++++++++++++++ .../tests/persistent-pwsh.cordis.yml | 42 +++++++++++++++++ .../persistent-pwsh-tool-turn/input.json | 7 +++ .../persistent-pwsh-tool-turn/session.jsonl | 34 ++++++++++++++ .../stdout.expected.jsonl | 4 ++ .../system-prompt.expected.md | 3 ++ .../tool-schemas.expected.json | 21 +++++++++ examples/package.json | 1 + pnpm-lock.yaml | 3 ++ 10 files changed, 170 insertions(+) create mode 100644 examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml create mode 100644 examples/acp-agent/tests/persistent-pwsh.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index c5bf81fc1f..b784529d4e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -61,6 +61,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 PERSISTENT_PWSH_CONFIG = fileURLToPath(new URL('./persistent-pwsh.cordis.yml', import.meta.url)) const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath( new URL('../background-job-admission.cordis.yml', import.meta.url), ) @@ -264,6 +265,15 @@ const SCENARIOS: Scenario[] = [ // newline and one recording replays on every host. pwshOnly: true, }, + { + name: 'persistent-pwsh-tool-turn', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'persistent-pwsh', + configPath: PERSISTENT_PWSH_CONFIG, + pwshOnly: true, + }, // Authored keyless replay through a test-only partial-Landlock provider: // the exact compatibility notice must stay ordinary stderr when the wrapped // `false` command exits 1, rather than becoming SANDBOX_UNAVAILABLE. diff --git a/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml b/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml new file mode 100644 index 0000000000..7b90b2298b --- /dev/null +++ b/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml @@ -0,0 +1,45 @@ +# Keyless replay counterpart to persistent-pwsh.cordis.yml. +- id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-pro + +- id: terminal + name: '@deepseek-ai/dsh-terminal' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + config: + shellDialect: pwsh + timeoutMs: 30000 + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh-persistent + name: '@deepseek-ai/dsh-tool-pwsh-persistent' diff --git a/examples/acp-agent/tests/persistent-pwsh.cordis.yml b/examples/acp-agent/tests/persistent-pwsh.cordis.yml new file mode 100644 index 0000000000..0b3cd18c70 --- /dev/null +++ b/examples/acp-agent/tests/persistent-pwsh.cordis.yml @@ -0,0 +1,42 @@ +# Minimal live counterpart for the persistent-pwsh-tool-turn snapshot composition. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + models: + - id: deepseek-v4-pro + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: terminal + name: '@deepseek-ai/dsh-terminal' + +- id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + config: + shellDialect: pwsh + timeoutMs: 30000 + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh-persistent + name: '@deepseek-ai/dsh-tool-pwsh-persistent' diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json new file mode 100644 index 0000000000..653e9a346c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl new file mode 100644 index 0000000000..220bdd516e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl @@ -0,0 +1,34 @@ +{"type":"session","version":0,"id":"0b7ff6ab-2486-4b2f-a43e-0fa29a1a46ed","createdAt":1785678162241,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785898456879,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"}]}} +{"type":"turn/start","seq":1,"time":1785898456880,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785898456880,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785678162261,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785898456903,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1785898456903,"data":{"title":"Use the pwsh tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":1785898456904,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1785898456904,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":8,"time":1785678162968,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":9,"time0":1785678163361,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]}} +{"type":"assistant/chunk","seq":31,"time":1785678163671,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":32,"time0":1785678163671,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,17,0,0,0,0,0,109,0,0,0,0,0,0,22,305],"id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","args":["","{","\"","command","\"",": ","\"","[","Console","]","::","Out",".Write","('","P","WS","H","_OK","')","\"","}"]}} +{"type":"assistant/chunk","seq":53,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}}} +{"type":"assistant/chunk","seq":54,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":56,"time":1785898456913,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1785898456913,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."},{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"82945de6-83e2-4b93-b6d2-89d58921eacf"},"usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}},"sourceEventSeqs":[8,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,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1785898456913,"data":{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}} +{"type":"tool/result","seq":59,"time":1785898456933,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_e0MSVSocL0o4UWjOdG4c2072"},"content":[{"type":"tool-result","toolCallId":"call_00_e0MSVSocL0o4UWjOdG4c2072","content":[{"type":"text","text":"PWSH_OK"}],"isError":false}],"role":"user","id":"874a846b-54b7-45cc-b3cb-edb8f868e1c5"}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":60,"time":1785898456933,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":61,"time":1785898456939,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":62,"time":1785678165136,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":63,"time0":1785678165312,"data":{"turn":1,"step":2,"index":0,"dt":[44,56,0,0,42,0,0,0,48,0,0,0,48,0,0,60,0,0,0,0,39,0,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," \"","P","WS","H","_OK","\"."," Now"," I"," need"," to"," reply"," with"," \"","D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":88,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":89,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":90,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":91,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":92,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":93,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":94,"time":1785898456944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":95,"time":1785898456944,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"36aaf6a0-1556-42e4-aed3-626caa8f7aaf"},"usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":96,"time":1785898456944,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":97,"time":1785898456944,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/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/persistent-pwsh-tool-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md new file mode 100644 index 0000000000..229b3a6f6c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md @@ -0,0 +1,3 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a concise snapshot agent working in {{cwd}}. diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json new file mode 100644 index 0000000000..20f5a3e55c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json @@ -0,0 +1,21 @@ +{ + "initial": [ + { + "name": "pwsh", + "description": "Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + ], + "changes": [] +} diff --git a/examples/package.json b/examples/package.json index cd348d5fe2..8b1d8875ba 100644 --- a/examples/package.json +++ b/examples/package.json @@ -95,6 +95,7 @@ "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-terminal": "workspace:*", "@deepseek-ai/dsh-tool-pwsh": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-skill": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1d23dd996..465df6b10a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -688,6 +688,9 @@ importers: '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:* version: link:../packages/shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:* + version: link:../packages/shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:../packages/workflow/tool-ralph From d0cdf520304d33c15b6ccf91a419518b26d1477c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:31:28 +0800 Subject: [PATCH 054/180] docs(pwsh): align persistent PTY contracts --- .../architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml | 4 ++-- .../architecture/2026-08-11-pwsh-persistent-pty.md | 6 +++--- .../architecture/2026-08-11-pwsh-persistent-pty.zh.md | 6 +++--- packages/shell/tool-pwsh-persistent/README.i18n.yaml | 4 ++-- packages/shell/tool-pwsh-persistent/README.md | 2 +- packages/shell/tool-pwsh-persistent/README.zh.md | 2 +- packages/terminal/terminal-bash/README.i18n.yaml | 4 ++-- packages/terminal/terminal-bash/README.md | 2 +- packages/terminal/terminal-bash/README.zh.md | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index ce1e88c050..80a1959b5a 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages 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-pwsh-persistent-pty.md -2026-08-11-pwsh-persistent-pty.md: 092302ec001909683f9b7056e982309889c7f23e -2026-08-11-pwsh-persistent-pty.zh.md: 857f78c66f109ababe8a5961fa923d819857fd88 +2026-08-11-pwsh-persistent-pty.md: 8353b3ab3cdbf20add22a55acb03312c94283602 +2026-08-11-pwsh-persistent-pty.zh.md: 95048a02416dfcf5f0ef2837d99a561008f6496f diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 092302ec00..8353b3ab3c 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -18,7 +18,7 @@ A model-facing persistent `pwsh` tool ships on Windows with the same contract as ### Windows substrate in `@deepseek-ai/dsh-subprocess-local` -`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes creation-time identities (pid-reuse fencing like the POSIX start identity), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only. +`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes creation identities with zero-time process-handle waits (pid-reuse fencing plus terminated-object detection), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only. `LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend. @@ -38,7 +38,7 @@ The minimal preset gates its persistent shell stack by platform with the #2234 ` ### Testing -The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. +The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result. ## Alternatives considered @@ -62,4 +62,4 @@ The Windows test surface follows master's exemption structure: terminal-bash and **Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks. -**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the prompt function's `[Console]::` call; the `Write-Host -NoNewline` fallback is designed and decided by the Windows-native lane. A model redefinition of the `prompt` function degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. +**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; commands then settle through the printable prompt and silence tier, while non-ASCII output may follow the host code page. A model redefinition of the `prompt` function likewise degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index 857f78c66f..95048a0241 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -18,7 +18,7 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS ### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座 -`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 创建时间身份(与 POSIX start-identity 相同的 PID 复用防护),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。 +`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 创建身份与进程句柄零时等待结合起来(同时防止 PID 复用并识别已终止的进程对象),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。 `LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。 @@ -38,7 +38,7 @@ minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell ### 测试 -Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 +Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。 ## 备选方案 @@ -62,4 +62,4 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo **输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。 -**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝 prompt 函数的 `[Console]::` 调用;`Write-Host -NoNewline` 回退已设计好,由 Windows-native 车道裁决。模型重定义 `prompt` 函数会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 +**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 diff --git a/packages/shell/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml index 0786399570..cc1175ca0e 100644 --- a/packages/shell/tool-pwsh-persistent/README.i18n.yaml +++ b/packages/shell/tool-pwsh-persistent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-pwsh-persistent/README.md -README.md: 7bb66477ab7ffe52039b0c699d48c9ca761ac04c -README.zh.md: b20041b1d42908d4d1e893455d825eafa2e2f87d +README.md: a57c940801606c2eef450f0e54fecb434c62a406 +README.zh.md: 4bd7ecdad08e504daa3ff6283f8b77ac9428385f diff --git a/packages/shell/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md index 7bb66477ab..a57c940801 100644 --- a/packages/shell/tool-pwsh-persistent/README.md +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -51,5 +51,5 @@ Append-only tool results follow the reusable request prefix. - A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path. - There is no interactive stdin during a command: a foreground command that reads input blocks until the readiness timeout, which resets the shell. - SIGTSTP/SIGHUP are unavailable on Windows (backend-rejected); SIGINT is delivered as a console-wide Ctrl-C input write, which at a prompt cancels the pending line instead of signalling a process. -- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the prompt function's `[Console]::` call; the backend's documented `Write-Host -NoNewline` fallback is selected by the Windows-native lane evidence. +- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the bootstrap's `[Console]::` encoding pin and prompt marker. Commands can still settle through the printable prompt and silence tier, but non-ASCII output may follow the host code page. - The BEL-terminated OSC marker remains a readiness signal only; a BEL event channel to the model stays deferred, aligned with the current implementation. diff --git a/packages/shell/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md index b20041b1d4..4bd7ecdad0 100644 --- a/packages/shell/tool-pwsh-persistent/README.zh.md +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -51,5 +51,5 @@ - 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。 - 命令执行期间没有交互 stdin:读取输入的前台命令会阻塞到就绪超时,随后重置 shell。 - SIGTSTP/SIGHUP 在 Windows 不可用(backend 拒绝);SIGINT 以控制台级 Ctrl-C 输入写入投递,在提示符处取消当前行而非向进程发信号。 -- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝 prompt 函数的 `[Console]::` 调用;backend 文档化的 `Write-Host -NoNewline` 回退由 Windows-native 车道证据裁决。 +- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker。命令仍可通过可打印提示符和静默档结算,但非 ASCII 输出可能沿用宿主代码页。 - BEL 终结的 OSC 标记仍只是就绪信号;面向模型的 BEL 事件通道保持延后,与当前实现对齐。 diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index e1b60c7d61..d6e544137a 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/terminal/terminal-bash/README.md -README.md: 82d0166ff5f63c108770b0a3011e5ebb7d63b433 -README.zh.md: 10d6260360e57ee6c81fb73e96f7cb952cfc9146 +README.md: 2f3f59b1acb88ff9905e78e7fc8d0d9fbcdbf0ba +README.zh.md: f3daa0a3bc9c160236ad19b35589778d99d48b36 diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index 82d0166ff5..2f3f59b1ac 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -34,6 +34,6 @@ A standing-policy change appends an owner-rendered superseding runtime-context s - Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported. - Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound. -- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. +- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny. The shell can still settle through the controlled printable prompt and silence tier, but marker readiness is unavailable and non-ASCII output may follow the host code page. - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer. - Sessions do not survive harness process exit. diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index 10d6260360..f3daa0a3bc 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -34,6 +34,6 @@ - 输出按行规范化;不支持全屏备用缓冲区交互。 - 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 -- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 +- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它。shell 仍可通过受控可打印提示符和静默档结算,但无法使用 marker 就绪,非 ASCII 输出也可能沿用宿主代码页。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 From 5648d4ad1c67f5785f5d622021af832582bb1674 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 16:38:44 +0800 Subject: [PATCH 055/180] test(typert): poll for the steady-state registration failure log --- packages/typert/loader/tests/loader.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 6dccd2b5dd..2f4b4cae04 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -398,9 +398,10 @@ describe('typert loader', () => { await ctx.loader.create({ name: '@fixture/steady-failure' }) await ctx.loader.await() - await new Promise(resolve => setTimeout(resolve, 20)) - - expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' })) + // The failing contributor's error is reported on the post-await flush. + await vi.waitFor(() => { + expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' })) + }, { timeout: 10_000 }) expect(ctx.typert.getPackage('@fixture/steady-failure')).toBeUndefined() }) }) From dc8991879ac3cb3fa80e6765f773a71dd3aeeac1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 16:38:52 +0800 Subject: [PATCH 056/180] test(agent-instructions): extend the workspace-context wait budget --- .../context/agent-instructions/tests/agent-instructions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 171f7322f0..52e2e79e00 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -223,7 +223,7 @@ async function workspaceContextOf(agent: Agent): Promise { message.source.kind === 'agent-instructions') expect(context).toBeDefined() return context! - }) + }, { timeout: 10_000 }) } async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise { From 4f64e20b417809ac8dbd681a450abc27ce768262 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 16:43:59 +0800 Subject: [PATCH 057/180] test(llm-pi-ai): cover compat validation branches --- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index f6f80eb878..83fd8958d0 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -919,6 +919,29 @@ describe('compat switches', () => { }) }) + it('ignores compat entries whose value is undefined', () => { + const models = modelsOf({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsStore: undefined }, + models: [{ id: 'acme-a' }], + }, + }, 'acme-gateway') + + expect(models.get('acme-a')?.compat).toBeUndefined() + }) + + it('rejects a model switch on an unrecognized protocol as having no configurable compat', () => { + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'acme-chat', + baseURL: 'https://acme.test', + models: [{ id: 'acme-a', compat: { supportsStore: false } }], + }, + })).toThrow(/its api is "acme-chat", which does not take it.*"acme-chat" offers no configurable compat/s) + }) + it('refuses a compat key no wire protocol declares instead of dropping it', () => { // The silent drop is what let an unreadable switch look applied: schemastery // passes unknown keys through, and resolution used to read only two fields. From 78fc31d06b8cd02736573191f9254a8a114e3e86 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 17:13:47 +0800 Subject: [PATCH 058/180] test(llm-pi-ai): remove unreachable compat fallback --- packages/llm/llm-pi-ai/src/catalog.ts | 1 - packages/llm/llm-pi-ai/tests/catalog.spec.ts | 13 ------------- 2 files changed, 14 deletions(-) diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 5a42ac9220..c945800bea 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -356,7 +356,6 @@ export type EveryOfferedFieldIsDocumented = AssertNever { - if (value === undefined) return [] const empty = typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value as object).length === 0 return empty ? [] : [[field, value] as const] diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 83fd8958d0..8997556871 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -919,19 +919,6 @@ describe('compat switches', () => { }) }) - it('ignores compat entries whose value is undefined', () => { - const models = modelsOf({ - 'acme-gateway': { - api: 'openai-completions', - baseURL: 'https://acme.test', - compat: { supportsStore: undefined }, - models: [{ id: 'acme-a' }], - }, - }, 'acme-gateway') - - expect(models.get('acme-a')?.compat).toBeUndefined() - }) - it('rejects a model switch on an unrecognized protocol as having no configurable compat', () => { expect(() => resolveProfiles({ 'acme-gateway': { From 8d3674695ba708d90719fad504357c1897fd7fce Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 19:00:37 +0800 Subject: [PATCH 059/180] docs(i18n): localize Chinese internal links --- .agents/notes/README.i18n.yaml | 2 +- .agents/notes/README.zh.md | 12 +- ...6-06-11-content-block-vocabulary.i18n.yaml | 2 +- .../2026-06-11-content-block-vocabulary.zh.md | 6 +- ...ev-invariants-over-deep-readonly.i18n.yaml | 2 +- ...11-dev-invariants-over-deep-readonly.zh.md | 2 +- ...026-06-11-event-sourced-sessions.i18n.yaml | 2 +- .../2026-06-11-event-sourced-sessions.zh.md | 2 +- ...06-11-microkernel-event-taxonomy.i18n.yaml | 2 +- ...026-06-11-microkernel-event-taxonomy.zh.md | 2 +- ...026-06-11-runtime-arg-validation.i18n.yaml | 2 +- .../2026-06-11-runtime-arg-validation.zh.md | 6 +- .../2026-06-13-capability-seams.i18n.yaml | 2 +- .../2026-06-13-capability-seams.zh.md | 4 +- .../2026-06-13-twin-llm-adapters.i18n.yaml | 2 +- .../2026-06-13-twin-llm-adapters.zh.md | 4 +- .../2026-06-14-session-persistence.i18n.yaml | 2 +- .../2026-06-14-session-persistence.zh.md | 14 +- ...06-17-filesystem-capability-seam.i18n.yaml | 2 +- ...026-06-17-filesystem-capability-seam.zh.md | 16 +- ...ifecycle-and-ownership-contracts.i18n.yaml | 2 +- ...nt-lifecycle-and-ownership-contracts.zh.md | 4 +- ...ed-persistence-write-coordinator.i18n.yaml | 2 +- ...shared-persistence-write-coordinator.zh.md | 4 +- .../2026-06-20-branded-ids.i18n.yaml | 2 +- .../architecture/2026-06-20-branded-ids.zh.md | 6 +- ...eneric-long-running-tool-runtime.i18n.yaml | 2 +- ...20-generic-long-running-tool-runtime.zh.md | 12 +- ...-21-bounded-llm-request-recovery.i18n.yaml | 2 +- ...6-06-21-bounded-llm-request-recovery.zh.md | 20 +- ...andatory-app-attribution-headers.i18n.yaml | 2 +- ...21-mandatory-app-attribution-headers.zh.md | 4 +- .../2026-06-24-web-capability-seam.i18n.yaml | 2 +- .../2026-06-24-web-capability-seam.zh.md | 10 +- ...06-26-file-context-as-event-gate.i18n.yaml | 2 +- ...026-06-26-file-context-as-event-gate.zh.md | 8 +- ...ash-stdin-env-trusted-plugin-api.i18n.yaml | 2 +- ...30-bash-stdin-env-trusted-plugin-api.zh.md | 6 +- ...026-06-30-event-domain-semantics.i18n.yaml | 2 +- .../2026-06-30-event-domain-semantics.zh.md | 6 +- ...6-07-02-tool-render-intent-union.i18n.yaml | 2 +- .../2026-07-02-tool-render-intent-union.zh.md | 4 +- ...bles-and-tool-guidance-ownership.i18n.yaml | 2 +- ...ariables-and-tool-guidance-ownership.zh.md | 4 +- ...bagent-provider-lifecycle-events.i18n.yaml | 2 +- ...5-subagent-provider-lifecycle-events.zh.md | 6 +- ...05-windows-jsonl-durable-publish.i18n.yaml | 2 +- ...-07-05-windows-jsonl-durable-publish.zh.md | 2 +- ...6-07-06-timeout-deadline-library.i18n.yaml | 2 +- .../2026-07-06-timeout-deadline-library.zh.md | 2 +- ...06-tool-result-retention-library.i18n.yaml | 2 +- ...-07-06-tool-result-retention-library.zh.md | 6 +- ...6-07-07-tool-call-timeout-policy.i18n.yaml | 2 +- .../2026-07-07-tool-call-timeout-policy.zh.md | 6 +- .../2026-07-08-agent-scope-contexts.i18n.yaml | 2 +- .../2026-07-08-agent-scope-contexts.zh.md | 10 +- ...26-07-08-tool-output-spill-files.i18n.yaml | 2 +- .../2026-07-08-tool-output-spill-files.zh.md | 4 +- ...n-pressure-and-overflow-recovery.i18n.yaml | 2 +- ...ction-pressure-and-overflow-recovery.zh.md | 6 +- ...cutable-sdk-runtime-distribution.i18n.yaml | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 8 +- ...07-12-agent-scope-runtime-design.i18n.yaml | 2 +- ...026-07-12-agent-scope-runtime-design.zh.md | 26 +- .../2026-07-12-scoped-layers-store.i18n.yaml | 2 +- .../2026-07-12-scoped-layers-store.zh.md | 2 +- ...-14-provider-routed-llm-adapters.i18n.yaml | 2 +- ...6-07-14-provider-routed-llm-adapters.zh.md | 6 +- ...2026-07-15-agent-initiator-scope.i18n.yaml | 2 +- .../2026-07-15-agent-initiator-scope.zh.md | 4 +- ...-model-catalog-and-acp-selection.i18n.yaml | 2 +- ...-llm-model-catalog-and-acp-selection.zh.md | 2 +- .../2026-07-15-lsp-capability-seam.i18n.yaml | 2 +- .../2026-07-15-lsp-capability-seam.zh.md | 2 +- ...07-15-replay-token-meter-service.i18n.yaml | 2 +- ...026-07-15-replay-token-meter-service.zh.md | 2 +- ...07-16-explicit-turn-cancellation.i18n.yaml | 2 +- ...026-07-16-explicit-turn-cancellation.zh.md | 6 +- ...19-cooperative-tool-cancellation.i18n.yaml | 2 +- ...-07-19-cooperative-tool-cancellation.zh.md | 4 +- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 2 +- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 14 +- ...7-19-gui-web-client-architecture.i18n.yaml | 2 +- ...26-07-19-gui-web-client-architecture.zh.md | 22 +- ...-package-owned-invariant-service.i18n.yaml | 2 +- ...7-19-package-owned-invariant-service.zh.md | 4 +- ...-19-zstandard-jsonl-session-logs.i18n.yaml | 2 +- ...6-07-19-zstandard-jsonl-session-logs.zh.md | 4 +- ...el-context-and-compaction-policy.i18n.yaml | 2 +- ...-model-context-and-compaction-policy.zh.md | 2 +- ...2-slot-type-chain-implementation.i18n.yaml | 2 +- ...07-22-slot-type-chain-implementation.zh.md | 4 +- ...send-and-coalesced-user-messages.i18n.yaml | 2 +- ...ied-send-and-coalesced-user-messages.zh.md | 16 +- ...7-23-client-plugin-loading-model.i18n.yaml | 2 +- ...26-07-23-client-plugin-loading-model.zh.md | 8 +- .../2026-07-23-toolview-dissolution.i18n.yaml | 2 +- .../2026-07-23-toolview-dissolution.zh.md | 4 +- ...xt-injection-from-turn-execution.i18n.yaml | 2 +- ...ontext-injection-from-turn-execution.zh.md | 4 +- ...-24-single-harness-home-resolver.i18n.yaml | 2 +- ...6-07-24-single-harness-home-resolver.zh.md | 2 +- ...tree-boot-and-transport-layering.i18n.yaml | 2 +- ...fig-tree-boot-and-transport-layering.zh.md | 8 +- ...ession-scope-and-provide-channel.i18n.yaml | 2 +- ...nt-session-scope-and-provide-channel.zh.md | 10 +- ...eb-command-surfaces-and-assembly.i18n.yaml | 2 +- ...25-web-command-surfaces-and-assembly.zh.md | 2 +- ...input-machine-and-slash-pipeline.i18n.yaml | 2 +- ...web-input-machine-and-slash-pipeline.zh.md | 2 +- .../2026-07-26-job-registry-seam.i18n.yaml | 2 +- .../2026-07-26-job-registry-seam.zh.md | 2 +- ...-26-packed-chunk-rows-by-default.i18n.yaml | 2 +- ...6-07-26-packed-chunk-rows-by-default.zh.md | 2 +- .../2026-07-26-subprocess-seam.i18n.yaml | 2 +- .../2026-07-26-subprocess-seam.zh.md | 6 +- ...ompiler-independent-typert-model.i18n.yaml | 2 +- ...27-compiler-independent-typert-model.zh.md | 8 +- ...directory-picker-capability-seam.i18n.yaml | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- ...ntified-immutable-message-values.i18n.yaml | 2 +- ...-identified-immutable-message-values.zh.md | 4 +- .../2026-07-28-user-settings-seam.i18n.yaml | 2 +- .../2026-07-28-user-settings-seam.zh.md | 6 +- ...-07-29-dsh-source-launch-tsx-esm.i18n.yaml | 2 +- ...2026-07-29-dsh-source-launch-tsx-esm.zh.md | 6 +- .../2026-07-29-package-regrouping.i18n.yaml | 2 +- .../2026-07-29-package-regrouping.zh.md | 10 +- ...est-level-llm-config-credentials.i18n.yaml | 2 +- ...request-level-llm-config-credentials.zh.md | 6 +- ...-29-terminal-llm-stream-failures.i18n.yaml | 2 +- ...6-07-29-terminal-llm-stream-failures.zh.md | 2 +- ...07-30-client-locale-full-rollout.i18n.yaml | 2 +- ...026-07-30-client-locale-full-rollout.zh.md | 4 +- ...-07-30-command-row-copy-contract.i18n.yaml | 2 +- ...2026-07-30-command-row-copy-contract.zh.md | 2 +- ...26-07-30-config-plane-boundaries.i18n.yaml | 2 +- .../2026-07-30-config-plane-boundaries.zh.md | 4 +- ...undaries-and-atomic-registration.i18n.yaml | 2 +- ...l-boundaries-and-atomic-registration.zh.md | 4 +- ...-followup-enqueue-and-owned-runs.i18n.yaml | 2 +- ...7-30-followup-enqueue-and-owned-runs.zh.md | 4 +- ...30-session-end-seed-log-boundary.i18n.yaml | 2 +- ...-07-30-session-end-seed-log-boundary.zh.md | 2 +- ...30-settings-write-path-integrity.i18n.yaml | 2 +- ...-07-30-settings-write-path-integrity.zh.md | 4 +- .../2026-07-30-web-config-plane.i18n.yaml | 2 +- .../2026-07-30-web-config-plane.zh.md | 6 +- ...claimed-pre-step-inbox-lifecycle.i18n.yaml | 2 +- ...-31-claimed-pre-step-inbox-lifecycle.zh.md | 2 +- ...runtime-portable-identifier-seam.i18n.yaml | 2 +- ...ode-runtime-portable-identifier-seam.zh.md | 2 +- ...code-runtime-python-fd3-protocol.i18n.yaml | 2 +- ...-31-code-runtime-python-fd3-protocol.zh.md | 2 +- ...26-08-01-packaged-ripgrep-search.i18n.yaml | 2 +- .../2026-08-01-packaged-ripgrep-search.zh.md | 8 +- ...-08-03-per-session-agent-presets.i18n.yaml | 2 +- ...2026-08-03-per-session-agent-presets.zh.md | 6 +- ...-pi-ai-declared-provider-catalog.i18n.yaml | 2 +- ...8-03-pi-ai-declared-provider-catalog.zh.md | 2 +- ...4-configuration-source-ownership.i18n.yaml | 2 +- ...08-04-configuration-source-ownership.zh.md | 4 +- ...-yaml-and-user-environment-layer.i18n.yaml | 2 +- ...ials-yaml-and-user-environment-layer.zh.md | 12 +- ...-a-provider-from-the-models-page.i18n.yaml | 2 +- ...ring-a-provider-from-the-models-page.zh.md | 2 +- ...-provider-endpoint-interrogation.i18n.yaml | 2 +- ...raft-provider-endpoint-interrogation.zh.md | 2 +- ...026-08-05-profile-plugin-bundles.i18n.yaml | 2 +- .../2026-08-05-profile-plugin-bundles.zh.md | 6 +- .../2026-08-05-session-preparation.i18n.yaml | 2 +- .../2026-08-05-session-preparation.zh.md | 6 +- ...ubagent-list-identity-projection.i18n.yaml | 2 +- ...06-subagent-list-identity-projection.zh.md | 16 +- ...arkdown-incremental-ast-renderer.i18n.yaml | 2 +- ...eb-markdown-incremental-ast-renderer.zh.md | 2 +- ...hared-feedback-telemetry-user-id.i18n.yaml | 2 +- ...07-shared-feedback-telemetry-user-id.zh.md | 2 +- ...ssion-persistence-write-batching.i18n.yaml | 2 +- ...d-session-persistence-write-batching.zh.md | 4 +- ...ient-tool-presentation-ownership.i18n.yaml | 2 +- ...8-client-tool-presentation-ownership.zh.md | 2 +- ...lient-conversation-node-assembly.i18n.yaml | 2 +- ...09-client-conversation-node-assembly.zh.md | 4 +- ...08-09-cordis-event-walk-backstop.i18n.yaml | 2 +- ...026-08-09-cordis-event-walk-backstop.zh.md | 2 +- ...headless-direct-core-entry-point.i18n.yaml | 2 +- ...-09-headless-direct-core-entry-point.zh.md | 2 +- ...8-10-fork-children-stay-one-shot.i18n.yaml | 2 +- ...26-08-10-fork-children-stay-one-shot.zh.md | 8 +- ...st-plane-ownership-after-presets.i18n.yaml | 2 +- ...0-host-plane-ownership-after-presets.zh.md | 4 +- ...ubagent-providers-in-shared-host.i18n.yaml | 2 +- ...ct-subagent-providers-in-shared-host.zh.md | 6 +- ...2026-08-10-remote-event-delivery.i18n.yaml | 2 +- .../2026-08-10-remote-event-delivery.zh.md | 2 +- .../2026-08-11-cmdline-seam-trim.i18n.yaml | 2 +- .../2026-08-11-cmdline-seam-trim.zh.md | 2 +- ...der-entry-disabled-interpolation.i18n.yaml | 2 +- ...-loader-entry-disabled-interpolation.zh.md | 4 +- ...aming-contract-and-rename-ledger.i18n.yaml | 2 +- ...ry-naming-contract-and-rename-ledger.zh.md | 2 +- ...ry-conversation-context-assembly.i18n.yaml | 2 +- ...ectory-conversation-context-assembly.zh.md | 8 +- ...12-plugin-owned-settings-surface.i18n.yaml | 2 +- ...-08-12-plugin-owned-settings-surface.zh.md | 2 +- ...13-session-content-search-opt-in.i18n.yaml | 2 +- ...-08-13-session-content-search-opt-in.zh.md | 2 +- ...ient-shells-and-dynamic-packages.i18n.yaml | 2 +- ...5-client-shells-and-dynamic-packages.zh.md | 2 +- ...-render-and-attachment-ownership.i18n.yaml | 2 +- ...ient-render-and-attachment-ownership.zh.md | 2 +- ...ction-summary-prefix-cache-reuse.i18n.yaml | 2 +- ...ompaction-summary-prefix-cache-reuse.zh.md | 2 +- ...ad-pre-identity-session-messages.i18n.yaml | 2 +- ...8-load-pre-identity-session-messages.zh.md | 4 +- ...d-scrollbars-and-reserved-gutter.i18n.yaml | 2 +- ...hemed-scrollbars-and-reserved-gutter.zh.md | 2 +- ...-07-28-web-agent-runtime-context.i18n.yaml | 2 +- ...2026-07-28-web-agent-runtime-context.zh.md | 2 +- ...2026-07-28-web-gui-feedback-loop.i18n.yaml | 2 +- .../2026-07-28-web-gui-feedback-loop.zh.md | 4 +- ...9-human-transcript-append-origin.i18n.yaml | 2 +- ...07-29-human-transcript-append-origin.zh.md | 4 +- ...7-29-pnpm-setup-runner-isolation.i18n.yaml | 2 +- ...26-07-29-pnpm-setup-runner-isolation.zh.md | 2 +- ...cky-composer-conversation-scroll.i18n.yaml | 2 +- ...-sticky-composer-conversation-scroll.zh.md | 6 +- ...29-web-details-session-lifecycle.i18n.yaml | 2 +- ...-07-29-web-details-session-lifecycle.zh.md | 4 +- ...07-30-approval-panel-command-cap.i18n.yaml | 2 +- ...026-07-30-approval-panel-command-cap.zh.md | 2 +- ...-30-bounded-overwrite-diff-basis.i18n.yaml | 2 +- ...6-07-30-bounded-overwrite-diff-basis.zh.md | 2 +- ...-30-composer-context-stack-order.i18n.yaml | 2 +- ...6-07-30-composer-context-stack-order.zh.md | 2 +- ...rce-checkout-workdir-distinction.i18n.yaml | 2 +- ...-source-checkout-workdir-distinction.zh.md | 2 +- ...ranscript-log-ordered-projection.i18n.yaml | 2 +- ...eb-transcript-log-ordered-projection.zh.md | 8 +- ...text-layers-share-one-scrollport.i18n.yaml | 2 +- ...ser-text-layers-share-one-scrollport.zh.md | 2 +- ...1-english-compaction-checkpoints.i18n.yaml | 2 +- ...07-31-english-compaction-checkpoints.zh.md | 2 +- ...-fail-loud-releases-the-terminal.i18n.yaml | 2 +- ...7-31-fail-loud-releases-the-terminal.zh.md | 2 +- ...same-basename-workspace-adoption.i18n.yaml | 2 +- ...-31-same-basename-workspace-adoption.zh.md | 4 +- ...-ask-user-delegated-caller-guard.i18n.yaml | 2 +- ...8-01-ask-user-delegated-caller-guard.zh.md | 2 +- ...-08-02-goal-round-wrapup-message.i18n.yaml | 2 +- ...2026-08-02-goal-round-wrapup-message.zh.md | 2 +- ...ions-require-completed-turn-tail.i18n.yaml | 2 +- ...-actions-require-completed-turn-tail.zh.md | 4 +- ...odo-first-composer-context-order.i18n.yaml | 2 +- ...02-todo-first-composer-context-order.zh.md | 2 +- ...3-cli-signal-shutdown-escalation.i18n.yaml | 2 +- ...08-03-cli-signal-shutdown-escalation.zh.md | 2 +- ...3-hmr-initial-scan-boot-deadlock.i18n.yaml | 2 +- ...08-03-hmr-initial-scan-boot-deadlock.zh.md | 2 +- ...-composer-tab-gutter-reservation.i18n.yaml | 2 +- ...8-04-composer-tab-gutter-reservation.zh.md | 10 +- ...-04-load-pre-react-loop-sessions.i18n.yaml | 2 +- ...6-08-04-load-pre-react-loop-sessions.zh.md | 4 +- ...ontext-meter-blind-to-compaction.i18n.yaml | 2 +- ...05-context-meter-blind-to-compaction.zh.md | 4 +- ...actions-require-a-completed-turn.i18n.yaml | 2 +- ...ail-actions-require-a-completed-turn.zh.md | 4 +- ...e-blank-session-reuse-membership.i18n.yaml | 2 +- ...space-blank-session-reuse-membership.zh.md | 2 +- ...list-agents-residency-vocabulary.i18n.yaml | 2 +- ...-06-list-agents-residency-vocabulary.zh.md | 4 +- ...06-provider-credential-lifecycle.i18n.yaml | 2 +- ...-08-06-provider-credential-lifecycle.zh.md | 2 +- ...-attribution-observed-top-ledger.i18n.yaml | 2 +- ...roll-attribution-observed-top-ledger.zh.md | 4 +- ...e-unpriced-replace-compatibility.i18n.yaml | 2 +- ...rface-unpriced-replace-compatibility.zh.md | 2 +- ...07-cancel-convergence-wake-latch.i18n.yaml | 2 +- ...-08-07-cancel-convergence-wake-latch.zh.md | 2 +- ...8-07-code-mode-executor-collapse.i18n.yaml | 2 +- ...26-08-07-code-mode-executor-collapse.zh.md | 2 +- ...-08-09-broken-preset-roster-rows.i18n.yaml | 2 +- ...2026-08-09-broken-preset-roster-rows.zh.md | 2 +- ...9-filesystem-absence-observation.i18n.yaml | 2 +- ...08-09-filesystem-absence-observation.zh.md | 4 +- ...-agents-join-their-parent-preset.i18n.yaml | 2 +- ...hild-agents-join-their-parent-preset.zh.md | 2 +- ...nimal-preset-owns-rl-composition.i18n.yaml | 2 +- ...0-minimal-preset-owns-rl-composition.zh.md | 8 +- ...08-10-pre-plugin-theme-bootstrap.i18n.yaml | 2 +- ...026-08-10-pre-plugin-theme-bootstrap.zh.md | 2 +- ...n-row-identity-covers-the-preset.i18n.yaml | 2 +- ...ssion-row-identity-covers-the-preset.zh.md | 2 +- ...sh-catalog-follows-preset-switch.i18n.yaml | 2 +- ...-slash-catalog-follows-preset-switch.zh.md | 2 +- ...bounded-background-job-admission.i18n.yaml | 2 +- ...-11-bounded-background-job-admission.zh.md | 2 +- ...26-08-11-owned-run-finish-reason.i18n.yaml | 2 +- .../2026-08-11-owned-run-finish-reason.zh.md | 2 +- ...nt-validates-its-own-composition.i18n.yaml | 2 +- ...-agent-validates-its-own-composition.zh.md | 6 +- ...chronous-subprocess-exit-cleanup.i18n.yaml | 2 +- ...-synchronous-subprocess-exit-cleanup.zh.md | 4 +- ...-overlay-seat-width-compensation.i18n.yaml | 2 +- ...oser-overlay-seat-width-compensation.zh.md | 4 +- ...ink-stale-profile-fallback-links.i18n.yaml | 2 +- ...-unlink-stale-profile-fallback-links.zh.md | 2 +- ...-bounded-cold-blank-verification.i18n.yaml | 2 +- ...8-13-bounded-cold-blank-verification.zh.md | 2 +- ...safari-textarea-soft-wrap-reflow.i18n.yaml | 2 +- ...-13-safari-textarea-soft-wrap-reflow.zh.md | 2 +- ...max-token-replay-state-alignment.i18n.yaml | 2 +- ...-15-max-token-replay-state-alignment.zh.md | 2 +- ...ent-bash-keeps-controlled-prompt.i18n.yaml | 2 +- ...sistent-bash-keeps-controlled-prompt.zh.md | 2 +- ...-image-dimension-admission-limit.i18n.yaml | 2 +- ...8-17-image-dimension-admission-limit.zh.md | 4 +- .../2026-06-14-acp-multi-session.i18n.yaml | 2 +- .../2026-06-14-acp-multi-session.zh.md | 6 +- .../feature/2026-06-15-code-mode.i18n.yaml | 2 +- .../feature/2026-06-15-code-mode.zh.md | 30 +- ...26-06-17-filesystem-tool-schemas.i18n.yaml | 2 +- .../2026-06-17-filesystem-tool-schemas.zh.md | 4 +- ...06-18-compaction-capability-seam.i18n.yaml | 2 +- ...026-06-18-compaction-capability-seam.zh.md | 14 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 2 +- .../2026-06-21-subagent-capability-seam.zh.md | 12 +- .../2026-06-22-acp-subagent-backend.i18n.yaml | 2 +- .../2026-06-22-acp-subagent-backend.zh.md | 4 +- .../2026-06-24-workspace-context.i18n.yaml | 2 +- .../2026-06-24-workspace-context.zh.md | 10 +- .../2026-06-29-todo-write-tool.i18n.yaml | 2 +- .../feature/2026-06-29-todo-write-tool.zh.md | 8 +- .../feature/2026-06-30-hook-bridges.i18n.yaml | 2 +- .../feature/2026-06-30-hook-bridges.zh.md | 8 +- .../2026-06-30-hook-protocol-lib.i18n.yaml | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 4 +- ...30-interception-extension-points.i18n.yaml | 2 +- ...-06-30-interception-extension-points.zh.md | 18 +- ...026-06-30-session-store-fork-api.i18n.yaml | 2 +- .../2026-06-30-session-store-fork-api.zh.md | 4 +- .../2026-07-05-dynamic-workflows.i18n.yaml | 2 +- .../2026-07-05-dynamic-workflows.zh.md | 8 +- .../feature/2026-07-05-skill-system.i18n.yaml | 2 +- .../feature/2026-07-05-skill-system.zh.md | 14 +- .../2026-07-06-approval-seam.i18n.yaml | 2 +- .../feature/2026-07-06-approval-seam.zh.md | 18 +- .../feature/2026-07-06-sandbox.i18n.yaml | 2 +- .../feature/2026-07-06-sandbox.zh.md | 34 +- .../2026-07-07-mcp-client-plugin.i18n.yaml | 2 +- .../2026-07-07-mcp-client-plugin.zh.md | 8 +- ...-07-08-background-subagent-tasks.i18n.yaml | 2 +- ...2026-07-08-background-subagent-tasks.zh.md | 4 +- ...-self-referential-cordis-toolset.i18n.yaml | 2 +- ...7-08-self-referential-cordis-toolset.zh.md | 10 +- ...ession-identity-and-log-location.i18n.yaml | 2 +- ...nt-session-identity-and-log-location.zh.md | 6 +- ...-10-parallel-tool-call-execution.i18n.yaml | 2 +- ...6-07-10-parallel-tool-call-execution.zh.md | 6 +- ...10-sqlite-session-query-provider.i18n.yaml | 2 +- ...-07-10-sqlite-session-query-provider.zh.md | 2 +- ...nt-persona-tool-filter-and-depth.i18n.yaml | 2 +- ...bagent-persona-tool-filter-and-depth.zh.md | 2 + ...26-07-14-cross-family-fs-sandbox.i18n.yaml | 2 +- .../2026-07-14-cross-family-fs-sandbox.zh.md | 4 +- ...16-durable-per-step-time-context.i18n.yaml | 2 +- ...-07-16-durable-per-step-time-context.zh.md | 2 +- .../2026-07-16-harness-level-loop.i18n.yaml | 2 +- .../2026-07-16-harness-level-loop.zh.md | 2 +- ...26-07-16-persistent-pty-sessions.i18n.yaml | 2 +- .../2026-07-16-persistent-pty-sessions.zh.md | 8 +- .../2026-07-19-human-goal-command.i18n.yaml | 2 +- .../2026-07-19-human-goal-command.zh.md | 2 +- ...26-07-19-model-facing-goal-tools.i18n.yaml | 2 +- .../2026-07-19-model-facing-goal-tools.zh.md | 4 +- ...rsisted-same-session-goal-domain.i18n.yaml | 2 +- ...9-persisted-same-session-goal-domain.zh.md | 4 +- ...7-19-plugin-command-registration.i18n.yaml | 2 +- ...26-07-19-plugin-command-registration.zh.md | 2 +- ...-20-code-mode-typed-tool-returns.i18n.yaml | 2 +- ...6-07-20-code-mode-typed-tool-returns.zh.md | 6 +- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 2 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 14 +- ...continuable-background-subagents.i18n.yaml | 2 +- ...-21-continuable-background-subagents.zh.md | 8 +- ...6-07-21-cross-session-references.i18n.yaml | 2 +- .../2026-07-21-cross-session-references.zh.md | 4 +- ...7-21-follow-instruction-symlinks.i18n.yaml | 2 +- ...26-07-21-follow-instruction-symlinks.zh.md | 8 +- ...07-21-instruction-load-all-dedup.i18n.yaml | 2 +- ...026-07-21-instruction-load-all-dedup.zh.md | 8 +- ...-07-21-local-instruction-overlay.i18n.yaml | 2 +- ...2026-07-21-local-instruction-overlay.zh.md | 8 +- ...-07-21-log-backed-session-titles.i18n.yaml | 2 +- ...2026-07-21-log-backed-session-titles.zh.md | 4 +- ...subagent-catalog-and-list-agents.i18n.yaml | 2 +- ...ble-subagent-catalog-and-list-agents.zh.md | 10 +- ...ge-input-and-durable-attachments.i18n.yaml | 2 +- ...-image-input-and-durable-attachments.zh.md | 8 +- ...3-session-telemetry-otel-revival.i18n.yaml | 2 +- ...07-23-session-telemetry-otel-revival.zh.md | 4 +- ...026-07-23-web-assistant-markdown.i18n.yaml | 2 +- .../2026-07-23-web-assistant-markdown.zh.md | 8 +- .../2026-07-23-web-todo-display.i18n.yaml | 2 +- .../feature/2026-07-23-web-todo-display.zh.md | 2 +- ...model-facing-session-query-tools.i18n.yaml | 2 +- ...-24-model-facing-session-query-tools.zh.md | 2 +- ...26-07-24-provider-retry-policies.i18n.yaml | 2 +- .../2026-07-24-provider-retry-policies.zh.md | 2 +- ...n-list-browsing-and-manual-order.i18n.yaml | 2 +- ...ssion-list-browsing-and-manual-order.zh.md | 10 +- ...7-25-subagent-policy-inheritance.i18n.yaml | 2 +- ...26-07-25-subagent-policy-inheritance.zh.md | 6 +- ...-07-25-workspace-ui-product-flow.i18n.yaml | 2 +- ...2026-07-25-workspace-ui-product-flow.zh.md | 10 +- ...26-07-26-code-dispatch-log-spill.i18n.yaml | 2 +- .../2026-07-26-code-dispatch-log-spill.zh.md | 2 +- ...7-26-code-dispatch-ui-foundation.i18n.yaml | 2 +- ...26-07-26-code-dispatch-ui-foundation.zh.md | 12 +- ...7-26-code-mode-chat-subcall-rows.i18n.yaml | 2 +- ...26-07-26-code-mode-chat-subcall-rows.zh.md | 4 +- ...code-mode-live-parallel-dispatch.i18n.yaml | 2 +- ...-26-code-mode-live-parallel-dispatch.zh.md | 4 +- ...-07-26-todo-parallel-in-progress.i18n.yaml | 2 +- ...2026-07-26-todo-parallel-in-progress.zh.md | 6 +- ...ative-workspace-directory-picker.i18n.yaml | 2 +- ...27-native-workspace-directory-picker.zh.md | 10 +- ...-27-trajectory-inspection-ledger.i18n.yaml | 2 +- ...6-07-27-trajectory-inspection-ledger.zh.md | 2 +- ...ipt-sdk-and-sdk-subagent-backend.i18n.yaml | 2 +- ...escript-sdk-and-sdk-subagent-backend.zh.md | 4 +- ...6-07-27-web-session-fork-actions.i18n.yaml | 2 +- .../2026-07-27-web-session-fork-actions.zh.md | 2 +- .../2026-07-27-web-session-search.i18n.yaml | 2 +- .../2026-07-27-web-session-search.zh.md | 4 +- ...07-27-web-subagent-conversations.i18n.yaml | 2 +- ...026-07-27-web-subagent-conversations.zh.md | 16 +- ...ntinuable-subagent-conversations.i18n.yaml | 2 +- ...8-continuable-subagent-conversations.zh.md | 10 +- .../2026-07-28-feedback-command.i18n.yaml | 2 +- .../feature/2026-07-28-feedback-command.zh.md | 4 +- ...26-07-28-skill-invocation-policy.i18n.yaml | 2 +- .../2026-07-28-skill-invocation-policy.zh.md | 2 +- ...28-todo-plan-clears-on-next-turn.i18n.yaml | 2 +- ...-07-28-todo-plan-clears-on-next-turn.zh.md | 6 +- .../2026-07-28-web-terminal-card.i18n.yaml | 2 +- .../2026-07-28-web-terminal-card.zh.md | 14 +- ...29-ask-question-web-presentation.i18n.yaml | 2 +- ...-07-29-ask-question-web-presentation.zh.md | 4 +- ...irectory-picker-adaptive-default.i18n.yaml | 2 +- ...29-directory-picker-adaptive-default.zh.md | 2 +- ...rsistent-bash-str-replace-editor.i18n.yaml | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- ...continuable-subagent-report-tool.i18n.yaml | 2 +- ...-30-continuable-subagent-report-tool.zh.md | 6 +- ...0-current-sandbox-policy-context.i18n.yaml | 2 +- ...07-30-current-sandbox-policy-context.zh.md | 2 +- ...seek-onboarding-credential-setup.i18n.yaml | 2 +- ...deepseek-onboarding-credential-setup.zh.md | 4 +- ...-plan-review-presentation-intent.i18n.yaml | 2 +- ...7-30-plan-review-presentation-intent.zh.md | 2 +- ...6-07-30-queued-manual-compaction.i18n.yaml | 2 +- .../2026-07-30-queued-manual-compaction.zh.md | 2 +- .../2026-07-30-search-render-card.i18n.yaml | 2 +- .../2026-07-30-search-render-card.zh.md | 10 +- ...versioned-gui-welcome-onboarding.i18n.yaml | 2 +- ...-30-versioned-gui-welcome-onboarding.zh.md | 4 +- .../2026-07-30-web-diff-card.i18n.yaml | 2 +- .../feature/2026-07-30-web-diff-card.zh.md | 12 +- ...026-07-30-web-read-card-frontend.i18n.yaml | 2 +- .../2026-07-30-web-read-card-frontend.zh.md | 12 +- .../2026-07-30-web-read-card.i18n.yaml | 2 +- .../feature/2026-07-30-web-read-card.zh.md | 12 +- ...6-07-30-web-result-card-frontend.i18n.yaml | 2 +- .../2026-07-30-web-result-card-frontend.zh.md | 14 +- .../2026-07-30-web-result-card.i18n.yaml | 2 +- .../feature/2026-07-30-web-result-card.zh.md | 8 +- .../2026-07-30-web-search-card.i18n.yaml | 2 +- .../feature/2026-07-30-web-search-card.zh.md | 10 +- ...7-31-code-mode-language-dispatch.i18n.yaml | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 4 +- ...31-even-out-shipped-tool-rosters.i18n.yaml | 2 +- ...-07-31-even-out-shipped-tool-rosters.zh.md | 18 +- ...mission-default-for-new-sessions.i18n.yaml | 2 +- ...-permission-default-for-new-sessions.zh.md | 2 +- ...7-31-telemetry-anonymous-user-id.i18n.yaml | 2 +- ...26-07-31-telemetry-anonymous-user-id.zh.md | 6 +- .../2026-07-31-web-default-search.i18n.yaml | 2 +- .../2026-07-31-web-default-search.zh.md | 2 +- ...7-31-web-telemetry-default-mount.i18n.yaml | 2 +- ...26-07-31-web-telemetry-default-mount.zh.md | 8 +- ...026-08-01-pwsh-tool-and-executor.i18n.yaml | 2 +- .../2026-08-01-pwsh-tool-and-executor.zh.md | 6 +- .../2026-08-01-windows-pwsh-default.i18n.yaml | 2 +- .../2026-08-01-windows-pwsh-default.zh.md | 8 +- ...2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 2 +- .../2026-08-02-pwsh-tool-bash-parity.zh.md | 4 +- ...ssion-search-not-shipped-default.i18n.yaml | 2 +- ...2-session-search-not-shipped-default.zh.md | 4 +- ...2-win32-in-process-folder-dialog.i18n.yaml | 2 +- ...08-02-win32-in-process-folder-dialog.zh.md | 4 +- .../2026-08-03-fs-tool-error-remedy.i18n.yaml | 2 +- .../2026-08-03-fs-tool-error-remedy.zh.md | 6 +- ...6-08-03-web-search-source-scroll.i18n.yaml | 2 +- .../2026-08-03-web-search-source-scroll.zh.md | 6 +- ...code-and-codex-subagent-backends.i18n.yaml | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 6 +- ...nter-revealed-sidebar-scrollbars.i18n.yaml | 2 +- ...-pointer-revealed-sidebar-scrollbars.zh.md | 4 +- ...b-context-source-and-steer-marks.i18n.yaml | 2 +- ...4-web-context-source-and-steer-marks.zh.md | 6 +- .../feature/2026-08-05-agent-teams.i18n.yaml | 2 +- .../feature/2026-08-05-agent-teams.zh.md | 2 +- ...composer-context-meter-breakdown.i18n.yaml | 2 +- ...-05-composer-context-meter-breakdown.zh.md | 2 +- ...26-08-05-context-form-vocabulary.i18n.yaml | 2 +- .../2026-08-05-context-form-vocabulary.zh.md | 4 +- .../2026-08-05-durable-web-schedule.i18n.yaml | 2 +- .../2026-08-05-durable-web-schedule.zh.md | 8 +- ...feedback-gated-session-telemetry.i18n.yaml | 2 +- ...-05-feedback-gated-session-telemetry.zh.md | 4 +- .../2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 2 +- .../2026-08-05-pwsh-ui-bash-parity.zh.md | 6 +- ...26-08-06-bundled-dsh-badge-skill.i18n.yaml | 2 +- .../2026-08-06-bundled-dsh-badge-skill.zh.md | 2 +- ...tinuable-child-report-obligation.i18n.yaml | 2 +- ...-continuable-child-report-obligation.zh.md | 10 +- ...6-continuable-subagent-interrupt.i18n.yaml | 2 +- ...08-06-continuable-subagent-interrupt.zh.md | 4 +- ...ned-subagent-settlement-delivery.i18n.yaml | 2 +- ...r-owned-subagent-settlement-delivery.zh.md | 4 +- ...-08-06-mcp-client-auto-reconnect.i18n.yaml | 2 +- ...2026-08-06-mcp-client-auto-reconnect.zh.md | 2 +- .../2026-08-06-web-install-manifest.i18n.yaml | 2 +- .../2026-08-06-web-install-manifest.zh.md | 2 +- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 2 +- ...26-08-06-web-queue-steer-all-gesture.zh.md | 2 +- ...default-model-follows-the-picker.i18n.yaml | 2 +- ...-07-default-model-follows-the-picker.zh.md | 2 +- ...knowledgement-sharing-disclosure.i18n.yaml | 2 +- ...k-acknowledgement-sharing-disclosure.zh.md | 2 +- ...6-08-07-web-inline-file-mentions.i18n.yaml | 2 +- .../2026-08-07-web-inline-file-mentions.zh.md | 2 +- ...-workspace-picker-composer-entry.i18n.yaml | 2 +- ...8-07-workspace-picker-composer-entry.zh.md | 2 +- ...8-user-explicit-skill-invocation.i18n.yaml | 2 +- ...08-08-user-explicit-skill-invocation.zh.md | 2 +- ...08-08-web-background-job-display.i18n.yaml | 2 +- ...026-08-08-web-background-job-display.zh.md | 8 +- ...ows-acl-restricted-token-sandbox.i18n.yaml | 2 +- ...windows-acl-restricted-token-sandbox.zh.md | 8 +- ...09-parallel-subagent-delegations.i18n.yaml | 2 +- ...-08-09-parallel-subagent-delegations.zh.md | 4 +- ...able-subagent-policy-inheritance.i18n.yaml | 2 +- ...tinuable-subagent-policy-inheritance.zh.md | 4 +- ...10-durable-workflow-runs-in-chat.i18n.yaml | 2 +- ...-08-10-durable-workflow-runs-in-chat.zh.md | 2 +- ...0-subagent-approval-pinned-never.i18n.yaml | 2 +- ...08-10-subagent-approval-pinned-never.zh.md | 4 +- ...2026-08-10-telemetry-default-off.i18n.yaml | 2 +- .../2026-08-10-telemetry-default-off.zh.md | 6 +- ...6-08-10-web-plugin-configuration.i18n.yaml | 2 +- .../2026-08-10-web-plugin-configuration.zh.md | 4 +- ...026-08-10-web-session-log-export.i18n.yaml | 2 +- .../2026-08-10-web-session-log-export.zh.md | 2 +- ...und-first-continuable-delegation.i18n.yaml | 2 +- ...kground-first-continuable-delegation.zh.md | 4 +- ...b-completion-wakes-an-idle-owner.i18n.yaml | 2 +- ...d-job-completion-wakes-an-idle-owner.zh.md | 10 +- ...l-profiles-bare-two-tool-runtime.i18n.yaml | 2 +- ...nimal-profiles-bare-two-tool-runtime.zh.md | 2 +- ...web-attachment-display-alignment.i18n.yaml | 2 +- ...-11-web-attachment-display-alignment.zh.md | 4 +- ...kspace-sidebar-order-and-folding.i18n.yaml | 2 +- ...-workspace-sidebar-order-and-folding.zh.md | 2 +- ...bagent-one-shot-background-tasks.i18n.yaml | 2 +- ...t-subagent-one-shot-background-tasks.zh.md | 2 +- ...mage-intake-and-limits-alignment.i18n.yaml | 2 +- ...eb-image-intake-and-limits-alignment.zh.md | 2 +- ...-shared-modal-product-onboarding.i18n.yaml | 2 +- ...8-13-shared-modal-product-onboarding.zh.md | 2 +- .../2026-06-11-quality-gates.i18n.yaml | 2 +- .../process/2026-06-11-quality-gates.zh.md | 8 +- .../2026-06-16-pnpm-over-yarn.i18n.yaml | 2 +- .../process/2026-06-16-pnpm-over-yarn.zh.md | 4 +- .../2026-06-17-ts-build-config.i18n.yaml | 2 +- .../process/2026-06-17-ts-build-config.zh.md | 2 +- ...6-06-18-markdown-cross-link-lint.i18n.yaml | 2 +- .../2026-06-18-markdown-cross-link-lint.zh.md | 6 +- ...-06-20-agent-note-classification.i18n.yaml | 2 +- ...2026-06-20-agent-note-classification.zh.md | 8 +- ...-20-core-data-structures-catalog.i18n.yaml | 2 +- ...6-06-20-core-data-structures-catalog.zh.md | 10 +- ...-bilingual-docs-and-pairing-gate.i18n.yaml | 4 +- ...6-07-02-bilingual-docs-and-pairing-gate.md | 2 +- ...7-02-bilingual-docs-and-pairing-gate.zh.md | 10 +- .../2026-07-02-tool-schema-catalog.i18n.yaml | 2 +- .../2026-07-02-tool-schema-catalog.zh.md | 4 +- ...2026-07-04-doc-tiers-and-budgets.i18n.yaml | 2 +- .../2026-07-04-doc-tiers-and-budgets.zh.md | 4 +- ...-07-05-uniform-agent-note-format.i18n.yaml | 2 +- ...2026-07-05-uniform-agent-note-format.zh.md | 2 +- .../2026-07-06-export-jsdoc-gate.i18n.yaml | 2 +- .../2026-07-06-export-jsdoc-gate.zh.md | 2 +- .../2026-07-06-node-engine-floor.i18n.yaml | 2 +- .../2026-07-06-node-engine-floor.zh.md | 2 +- ...26-07-06-parallel-pre-push-gates.i18n.yaml | 2 +- .../2026-07-06-parallel-pre-push-gates.zh.md | 4 +- ...ackage-model-experience-contract.i18n.yaml | 2 +- ...12-package-model-experience-contract.zh.md | 4 +- ...13-documentation-site-projection.i18n.yaml | 2 +- ...-07-13-documentation-site-projection.zh.md | 4 +- ...emove-generated-agent-note-index.i18n.yaml | 2 +- ...19-remove-generated-agent-note-index.zh.md | 2 +- ...nt-notes-for-non-trivial-changes.i18n.yaml | 2 +- ...-agent-notes-for-non-trivial-changes.zh.md | 2 +- .../2026-07-19-web-styling-system.i18n.yaml | 2 +- .../2026-07-19-web-styling-system.zh.md | 2 +- .../2026-07-20-gui-testing-system.i18n.yaml | 2 +- .../2026-07-20-gui-testing-system.zh.md | 8 +- ...rial-cross-platform-ci-reference.i18n.yaml | 2 +- ...1-serial-cross-platform-ci-reference.zh.md | 4 +- ...ence-based-larger-hosted-runners.i18n.yaml | 2 +- ...evidence-based-larger-hosted-runners.zh.md | 10 +- .../2026-07-22-fast-local-git-hooks.i18n.yaml | 2 +- .../2026-07-22-fast-local-git-hooks.zh.md | 4 +- ...-07-22-product-first-root-readme.i18n.yaml | 2 +- ...2026-07-22-product-first-root-readme.zh.md | 2 +- ...fig-solution-root-two-aggregates.i18n.yaml | 2 +- ...sconfig-solution-root-two-aggregates.zh.md | 6 +- ...ortable-required-pull-request-ci.i18n.yaml | 2 +- ...23-portable-required-pull-request-ci.zh.md | 4 +- ...efed-minimal-translation-updates.i18n.yaml | 2 +- ...-briefed-minimal-translation-updates.zh.md | 4 +- ...6-dependencies-over-hand-rolling.i18n.yaml | 2 +- ...07-26-dependencies-over-hand-rolling.zh.md | 8 +- ...-incremental-pr-base-retargeting.i18n.yaml | 2 +- ...7-26-incremental-pr-base-retargeting.zh.md | 2 +- ...26-web-syntax-highlighting-shiki.i18n.yaml | 2 +- ...-07-26-web-syntax-highlighting-shiki.zh.md | 4 +- ...07-27-dependabot-version-updates.i18n.yaml | 2 +- ...026-07-27-dependabot-version-updates.zh.md | 2 +- ...26-07-27-worktree-local-lefthook.i18n.yaml | 2 +- .../2026-07-27-worktree-local-lefthook.zh.md | 2 +- ...subsystem-cordis-surface-regions.i18n.yaml | 2 +- ...per-subsystem-cordis-surface-regions.zh.md | 4 +- .../2026-07-29-oxlint-linter.i18n.yaml | 2 +- .../process/2026-07-29-oxlint-linter.zh.md | 4 +- ...fig-source-plane-resolution-gate.i18n.yaml | 2 +- ...-config-source-plane-resolution-gate.zh.md | 2 +- ...30-independent-ci-consumer-build.i18n.yaml | 2 +- ...-07-30-independent-ci-consumer-build.zh.md | 2 +- ...thub-stacks-and-optional-rebases.i18n.yaml | 2 +- ...e-github-stacks-and-optional-rebases.zh.md | 4 +- ...package-anchored-subsystem-pages.i18n.yaml | 2 +- ...-03-package-anchored-subsystem-pages.zh.md | 12 +- ...6-in-repository-landlock-release.i18n.yaml | 2 +- ...08-06-in-repository-landlock-release.zh.md | 6 +- ...remotes-generated-contract-build.i18n.yaml | 2 +- ...api-remotes-generated-contract-build.zh.md | 2 +- ...matic-translation-pairing-merges.i18n.yaml | 2 +- ...automatic-translation-pairing-merges.zh.md | 2 + ...08-08-browser-gif-evidence-chain.i18n.yaml | 2 +- ...026-08-08-browser-gif-evidence-chain.zh.md | 2 +- ...outine-documentation-translation.i18n.yaml | 2 +- ...ht-routine-documentation-translation.zh.md | 2 +- ...8-native-windows-pull-request-ci.i18n.yaml | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 4 +- ...-09-chinese-contract-terminology.i18n.yaml | 2 +- ...6-08-09-chinese-contract-terminology.zh.md | 2 +- ...-09-committed-artifact-citations.i18n.yaml | 2 +- ...6-08-09-committed-artifact-citations.zh.md | 2 +- ...-names-actors-and-recorded-facts.i18n.yaml | 2 +- ...rose-names-actors-and-recorded-facts.zh.md | 2 +- ...26-08-09-md-fragment-anchor-gate.i18n.yaml | 2 +- .../2026-08-09-md-fragment-anchor-gate.zh.md | 2 +- ...6-08-09-oxlint-only-fix-workflow.i18n.yaml | 2 +- .../2026-08-09-oxlint-only-fix-workflow.zh.md | 2 +- ...2026-08-10-npm-release-sequences.i18n.yaml | 2 +- .../2026-08-10-npm-release-sequences.zh.md | 4 +- ...026-08-10-vendor-package-rescope.i18n.yaml | 2 +- .../2026-08-10-vendor-package-rescope.zh.md | 2 +- ...blic-vendor-and-native-sequences.i18n.yaml | 2 +- ...3-public-vendor-and-native-sequences.zh.md | 2 +- ...-13-published-document-fragments.i18n.yaml | 4 +- ...2026-08-13-published-document-fragments.md | 4 +- ...6-08-13-published-document-fragments.zh.md | 4 +- ...-08-18-localized-bilingual-links.i18n.yaml | 6 + .../2026-08-18-localized-bilingual-links.md | 37 ++ ...2026-08-18-localized-bilingual-links.zh.md | 37 ++ ...-19-drop-mutable-session-summary.i18n.yaml | 2 +- ...6-06-19-drop-mutable-session-summary.zh.md | 4 +- ...2026-06-20-public-agent-stop-api.i18n.yaml | 2 +- .../2026-06-20-public-agent-stop-api.zh.md | 4 +- ...ove-agent-boundary-mirror-events.i18n.yaml | 2 +- ...-remove-agent-boundary-mirror-events.zh.md | 10 +- ...06-20-unify-agent-and-session-id.i18n.yaml | 2 +- ...026-06-20-unify-agent-and-session-id.zh.md | 2 +- .../2026-06-26-fsspec-style-fs-seam.i18n.yaml | 2 +- .../2026-06-26-fsspec-style-fs-seam.zh.md | 12 +- ...4-tighten-hook-protocol-contract.i18n.yaml | 2 +- ...07-04-tighten-hook-protocol-contract.zh.md | 6 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 2 +- .../2026-07-17-one-send-one-turn.zh.md | 2 +- ...-20-remove-stdio-and-echo-agents.i18n.yaml | 2 +- ...6-07-20-remove-stdio-and-echo-agents.zh.md | 6 +- ...nwrap-injected-content-envelopes.i18n.yaml | 2 +- ...20-unwrap-injected-content-envelopes.zh.md | 2 +- ...lan-specific-collaboration-state.i18n.yaml | 2 +- ...22-plan-specific-collaboration-state.zh.md | 2 +- ...-23-acp-automation-only-protocol.i18n.yaml | 2 +- ...6-07-23-acp-automation-only-protocol.zh.md | 6 +- ...collapse-persistence-flush-state.i18n.yaml | 2 +- ...-23-collapse-persistence-flush-state.zh.md | 2 +- ...nt-loop-observable-state-machine.i18n.yaml | 2 +- ...-agent-loop-observable-state-machine.zh.md | 14 +- ...6-merge-subagent-control-service.i18n.yaml | 2 +- ...07-26-merge-subagent-control-service.zh.md | 2 +- ...subagent-continuation-operations.i18n.yaml | 2 +- ...med-subagent-continuation-operations.zh.md | 2 +- ...6-07-28-local-json-tree-renderer.i18n.yaml | 2 +- .../2026-07-28-local-json-tree-renderer.zh.md | 2 +- ...-remove-synthetic-log-only-turns.i18n.yaml | 2 +- ...7-28-remove-synthetic-log-only-turns.zh.md | 2 +- .../2026-07-30-private-agent-send.i18n.yaml | 2 +- .../2026-07-30-private-agent-send.zh.md | 2 +- ...y-neutral-sandbox-policy-context.i18n.yaml | 2 +- ...ility-neutral-sandbox-policy-context.zh.md | 2 +- ...t-invariants-from-shipped-config.i18n.yaml | 2 +- ...-omit-invariants-from-shipped-config.zh.md | 2 +- .../2026-08-04-remove-tui-package.i18n.yaml | 2 +- .../2026-08-04-remove-tui-package.zh.md | 4 +- ...6-buffer-free-feedback-telemetry.i18n.yaml | 2 +- ...08-06-buffer-free-feedback-telemetry.zh.md | 2 +- ...r-bubbles-drop-the-branch-action.i18n.yaml | 2 +- ...-user-bubbles-drop-the-branch-action.zh.md | 4 +- ...08-08-copy-only-preset-authoring.i18n.yaml | 2 +- ...026-08-08-copy-only-preset-authoring.zh.md | 2 +- .../2026-08-08-remove-cli-demo.i18n.yaml | 2 +- .../2026-08-08-remove-cli-demo.zh.md | 2 +- ...8-09-bounded-fixed-rate-schedule.i18n.yaml | 2 +- ...26-08-09-bounded-fixed-rate-schedule.zh.md | 2 +- ...10-default-presets-single-editor.i18n.yaml | 2 +- ...-08-10-default-presets-single-editor.zh.md | 2 +- ...ce-run-without-managed-installer.i18n.yaml | 2 +- ...source-run-without-managed-installer.zh.md | 2 +- ...ve-steering-interjection-caption.i18n.yaml | 2 +- ...remove-steering-interjection-caption.zh.md | 4 +- ...026-08-11-cmdline-program-action.i18n.yaml | 2 +- .../2026-08-11-cmdline-program-action.zh.md | 2 +- ...11-quickstart-documentation-home.i18n.yaml | 2 +- ...-08-11-quickstart-documentation-home.zh.md | 2 +- ...-11-remove-sdk-project-toolchain.i18n.yaml | 2 +- ...6-08-11-remove-sdk-project-toolchain.zh.md | 2 +- ...ludes-product-subagent-providers.i18n.yaml | 2 +- ...-excludes-product-subagent-providers.zh.md | 2 +- ...eparate-source-launch-from-build.i18n.yaml | 2 +- ...12-separate-source-launch-from-build.zh.md | 2 +- ...-13-remove-first-run-beta-notice.i18n.yaml | 2 +- ...6-08-13-remove-first-run-beta-notice.zh.md | 4 +- ...026-06-11-property-based-testing.i18n.yaml | 2 +- .../2026-06-11-property-based-testing.zh.md | 2 +- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 2 +- .../2026-06-19-acp-snapshot-tests.zh.md | 12 +- .../2026-06-19-real-api-e2e-ci.i18n.yaml | 2 +- .../testing/2026-06-19-real-api-e2e-ci.zh.md | 4 +- ...-fork-child-replay-seed-boundary.i18n.yaml | 2 +- ...6-22-fork-child-replay-seed-boundary.zh.md | 4 +- ...6-06-22-subagent-snapshot-replay.i18n.yaml | 2 +- .../2026-06-22-subagent-snapshot-replay.zh.md | 4 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 10 +- ...-30-web-browser-snapshot-ci-gate.i18n.yaml | 2 +- ...6-07-30-web-browser-snapshot-ci-gate.zh.md | 4 +- ...d-python-runtime-pull-request-ci.i18n.yaml | 2 +- ...uired-python-runtime-pull-request-ci.zh.md | 4 +- ...n-minimal-model-visible-snapshot.i18n.yaml | 2 +- ...ython-minimal-model-visible-snapshot.zh.md | 2 +- .../2026-06-16-typed-event-schemas.i18n.yaml | 2 +- .../2026-06-16-typed-event-schemas.zh.md | 6 +- ...on-through-tool-capability-seams.i18n.yaml | 2 +- ...lation-through-tool-capability-seams.zh.md | 2 +- ...-domain-kv-storage-and-workspace.i18n.yaml | 2 +- ...7-24-domain-kv-storage-and-workspace.zh.md | 4 +- ...-25-client-settings-locale-theme.i18n.yaml | 2 +- ...6-07-25-client-settings-locale-theme.zh.md | 4 +- ...ssion-projection-and-command-log.i18n.yaml | 2 +- ...7-session-projection-and-command-log.zh.md | 4 +- ...root-and-derived-medium-recovery.i18n.yaml | 2 +- ...age-root-and-derived-medium-recovery.zh.md | 2 +- ...7-29-durable-last-activity-index.i18n.yaml | 2 +- ...26-07-29-durable-last-activity-index.zh.md | 12 +- ...8-semantic-composer-chain-phases.i18n.yaml | 2 +- ...08-08-semantic-composer-chain-phases.zh.md | 2 +- ...-unary-apiproxy-remote-migration.i18n.yaml | 2 +- ...8-10-unary-apiproxy-remote-migration.zh.md | 4 +- ...026-06-30-pre-tool-input-rewrite.i18n.yaml | 2 +- .../2026-06-30-pre-tool-input-rewrite.zh.md | 2 +- ...-07-08-interactive-side-sessions.i18n.yaml | 2 +- ...2026-07-08-interactive-side-sessions.zh.md | 6 +- .../feature/2026-08-04-task-surface.i18n.yaml | 2 +- .../feature/2026-08-04-task-surface.zh.md | 10 +- ...2026-06-11-api-extractor-reports.i18n.yaml | 2 +- .../2026-06-11-api-extractor-reports.zh.md | 2 +- ...-06-11-architectural-conformance.i18n.yaml | 2 +- ...2026-06-11-architectural-conformance.zh.md | 4 +- ...11-supply-chain-and-vendor-drift.i18n.yaml | 2 +- ...-06-11-supply-chain-and-vendor-drift.zh.md | 2 +- ...06-20-discover-package-inventory.i18n.yaml | 2 +- ...026-06-20-discover-package-inventory.zh.md | 2 +- ...3-human-review-skill-maintenance.i18n.yaml | 2 +- ...07-13-human-review-skill-maintenance.zh.md | 2 +- .../2026-06-11-mutation-testing.i18n.yaml | 2 +- .../testing/2026-06-11-mutation-testing.zh.md | 2 +- ...ndstrip-for-windows-sandbox-rung.i18n.yaml | 2 +- ...e-landstrip-for-windows-sandbox-rung.zh.md | 4 +- ...ssembled-assistant-messages-only.i18n.yaml | 2 +- ...20-assembled-assistant-messages-only.zh.md | 6 +- ...-20-drop-bash-output-spill-files.i18n.yaml | 2 +- ...6-06-20-drop-bash-output-spill-files.zh.md | 4 +- ...-20-drop-durable-step-boundaries.i18n.yaml | 2 +- ...6-06-20-drop-durable-step-boundaries.zh.md | 2 +- ...ld-session-persistence-interface.i18n.yaml | 2 +- ...0-fold-session-persistence-interface.zh.md | 4 +- ...06-20-truncate-interrupted-turns.i18n.yaml | 2 +- ...026-06-20-truncate-interrupted-turns.zh.md | 4 +- ...-prune-unused-skill-registry-api.i18n.yaml | 2 +- ...7-12-prune-unused-skill-registry-api.zh.md | 2 +- ...19-fold-compaction-package-split.i18n.yaml | 2 +- ...-07-19-fold-compaction-package-split.zh.md | 4 +- ...ency-swaps-rejected-by-nih-audit.i18n.yaml | 2 +- ...pendency-swaps-rejected-by-nih-audit.zh.md | 36 +- .agents/skills/dsh-translate-docs/SKILL.md | 4 +- README.i18n.yaml | 2 +- README.zh.md | 10 +- apps/cli/README.i18n.yaml | 2 +- apps/cli/README.zh.md | 8 +- apps/cli/reference/README.i18n.yaml | 2 +- apps/cli/reference/README.zh.md | 6 +- apps/web/tests/README.i18n.yaml | 2 +- apps/web/tests/README.zh.md | 2 +- docs/agent-lifecycle.i18n.yaml | 2 +- docs/agent-lifecycle.zh.md | 2 +- docs/architecture.i18n.yaml | 2 +- docs/architecture.zh.md | 34 +- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.zh.md | 16 +- .../adding-a-conversation-node.i18n.yaml | 2 +- .../cookbook/adding-a-conversation-node.zh.md | 2 +- docs/cookbook/adding-a-package.i18n.yaml | 2 +- docs/cookbook/adding-a-package.zh.md | 10 +- .../cookbook/adding-a-settings-card.i18n.yaml | 2 +- docs/cookbook/adding-a-settings-card.zh.md | 2 +- docs/cookbook/adding-a-tool.i18n.yaml | 2 +- docs/cookbook/adding-a-tool.zh.md | 14 +- .../adding-a-vendored-package.i18n.yaml | 2 +- docs/cookbook/adding-a-vendored-package.zh.md | 6 +- docs/cookbook/adding-an-llm-adapter.i18n.yaml | 2 +- docs/cookbook/adding-an-llm-adapter.zh.md | 4 +- docs/cookbook/extension-cookbook.i18n.yaml | 2 +- docs/cookbook/extension-cookbook.zh.md | 18 +- .../maintaining-dsh-code-review.i18n.yaml | 2 +- .../maintaining-dsh-code-review.zh.md | 2 +- ...sponding-to-pr-review-on-a-stack.i18n.yaml | 2 +- .../responding-to-pr-review-on-a-stack.zh.md | 2 +- docs/cordis-api/context.i18n.yaml | 2 +- docs/cordis-api/context.zh.md | 2 +- docs/cordis-api/events.i18n.yaml | 2 +- docs/cordis-api/events.zh.md | 2 +- docs/cordis-primer.i18n.yaml | 2 +- docs/cordis-primer.zh.md | 2 +- .../cordis-tutorial/01-first-plugin.i18n.yaml | 2 +- docs/cordis-tutorial/01-first-plugin.zh.md | 8 +- .../02-lifecycle-and-effects.i18n.yaml | 2 +- .../02-lifecycle-and-effects.zh.md | 8 +- docs/cordis-tutorial/03-services.i18n.yaml | 2 +- docs/cordis-tutorial/03-services.zh.md | 8 +- docs/cordis-tutorial/04-events.i18n.yaml | 2 +- docs/cordis-tutorial/04-events.zh.md | 8 +- docs/cordis-tutorial/05-config.i18n.yaml | 2 +- docs/cordis-tutorial/05-config.zh.md | 4 +- .../06-composition-and-hmr.i18n.yaml | 2 +- .../06-composition-and-hmr.zh.md | 6 +- .../07-into-the-harness.i18n.yaml | 2 +- .../cordis-tutorial/07-into-the-harness.zh.md | 12 +- docs/cordis-tutorial/index.i18n.yaml | 2 +- docs/cordis-tutorial/index.zh.md | 20 +- docs/defensive-patterns.i18n.yaml | 2 +- docs/defensive-patterns.zh.md | 4 +- docs/development.i18n.yaml | 2 +- docs/development.zh.md | 24 +- docs/graph-atlas.i18n.yaml | 2 +- docs/graph-atlas.zh.md | 16 +- docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 6 +- docs/i18n/README.zh.md | 16 +- docs/i18n/style-samples.md | 2 +- docs/i18n/translation-prompt.md | 6 +- docs/i18n/translation-rules.i18n.yaml | 4 +- docs/i18n/translation-rules.md | 4 +- docs/i18n/translation-rules.zh.md | 6 +- docs/persistence-catalog.i18n.yaml | 2 +- docs/persistence-catalog.zh.md | 34 +- ...-acp-default-export-drops-inject.i18n.yaml | 2 +- ...0001-acp-default-export-drops-inject.zh.md | 2 +- ...ession-disabled-filesystem-tools.i18n.yaml | 2 +- ...expression-disabled-filesystem-tools.zh.md | 2 +- ...ice-misclassified-child-failures.i18n.yaml | 2 +- ...-notice-misclassified-child-failures.zh.md | 2 +- docs/postmortem/README.i18n.yaml | 2 +- docs/postmortem/README.zh.md | 10 +- docs/rescope.i18n.yaml | 2 +- docs/rescope.zh.md | 2 +- docs/subsystems/README.i18n.yaml | 2 +- docs/subsystems/README.zh.md | 96 ++--- docs/subsystems/approval.i18n.yaml | 2 +- docs/subsystems/approval.zh.md | 6 +- docs/subsystems/attachment.i18n.yaml | 2 +- docs/subsystems/attachment.zh.md | 2 +- docs/subsystems/client-modules.i18n.yaml | 2 +- docs/subsystems/client-modules.zh.md | 6 +- docs/subsystems/code-runtime.i18n.yaml | 2 +- docs/subsystems/code-runtime.zh.md | 6 +- docs/subsystems/commands.i18n.yaml | 2 +- docs/subsystems/commands.zh.md | 6 +- docs/subsystems/compaction.i18n.yaml | 2 +- docs/subsystems/compaction.zh.md | 16 +- docs/subsystems/core.i18n.yaml | 2 +- docs/subsystems/core.zh.md | 70 ++-- docs/subsystems/credentials.i18n.yaml | 2 +- docs/subsystems/credentials.zh.md | 2 +- docs/subsystems/extensions.i18n.yaml | 2 +- docs/subsystems/extensions.zh.md | 8 +- docs/subsystems/feedback.i18n.yaml | 2 +- docs/subsystems/feedback.zh.md | 2 +- docs/subsystems/filesystem.i18n.yaml | 2 +- docs/subsystems/filesystem.zh.md | 6 +- docs/subsystems/goal.i18n.yaml | 2 +- docs/subsystems/goal.zh.md | 12 +- docs/subsystems/invariants.i18n.yaml | 2 +- docs/subsystems/invariants.zh.md | 8 +- docs/subsystems/jobs.i18n.yaml | 2 +- docs/subsystems/jobs.zh.md | 10 +- docs/subsystems/llm-streaming.i18n.yaml | 2 +- docs/subsystems/llm-streaming.zh.md | 18 +- docs/subsystems/lsp.i18n.yaml | 2 +- docs/subsystems/lsp.zh.md | 4 +- docs/subsystems/permission-presets.i18n.yaml | 2 +- docs/subsystems/permission-presets.zh.md | 8 +- docs/subsystems/persistence.i18n.yaml | 2 +- docs/subsystems/persistence.zh.md | 16 +- docs/subsystems/plan.i18n.yaml | 2 +- docs/subsystems/plan.zh.md | 16 +- docs/subsystems/sandbox.i18n.yaml | 2 +- docs/subsystems/sandbox.zh.md | 10 +- docs/subsystems/schedule.i18n.yaml | 2 +- docs/subsystems/schedule.zh.md | 8 +- docs/subsystems/scope.i18n.yaml | 2 +- docs/subsystems/scope.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 2 +- docs/subsystems/session-projection.zh.md | 8 +- docs/subsystems/session-query.i18n.yaml | 2 +- docs/subsystems/session-query.zh.md | 4 +- docs/subsystems/session-reference.i18n.yaml | 2 +- docs/subsystems/session-reference.zh.md | 4 +- docs/subsystems/session-telemetry.i18n.yaml | 2 +- docs/subsystems/session-telemetry.zh.md | 10 +- docs/subsystems/session-title.i18n.yaml | 2 +- docs/subsystems/session-title.zh.md | 6 +- docs/subsystems/session.i18n.yaml | 2 +- docs/subsystems/session.zh.md | 40 +- docs/subsystems/settings.i18n.yaml | 2 +- docs/subsystems/settings.zh.md | 2 +- docs/subsystems/shell.i18n.yaml | 2 +- docs/subsystems/shell.zh.md | 18 +- docs/subsystems/skills.i18n.yaml | 2 +- docs/subsystems/skills.zh.md | 8 +- docs/subsystems/spill.i18n.yaml | 2 +- docs/subsystems/spill.zh.md | 6 +- docs/subsystems/storage.i18n.yaml | 2 +- docs/subsystems/storage.zh.md | 10 +- docs/subsystems/subagent.i18n.yaml | 2 +- docs/subsystems/subagent.zh.md | 24 +- docs/subsystems/subprocess.i18n.yaml | 2 +- docs/subsystems/subprocess.zh.md | 6 +- docs/subsystems/system-prompt.i18n.yaml | 2 +- docs/subsystems/system-prompt.zh.md | 6 +- docs/subsystems/team.i18n.yaml | 2 +- docs/subsystems/team.zh.md | 10 +- docs/subsystems/terminal.i18n.yaml | 2 +- docs/subsystems/terminal.zh.md | 6 +- docs/subsystems/token-meter.i18n.yaml | 2 +- docs/subsystems/token-meter.zh.md | 4 +- docs/subsystems/tools.i18n.yaml | 2 +- docs/subsystems/tools.zh.md | 20 +- docs/subsystems/typert.i18n.yaml | 2 +- docs/subsystems/typert.zh.md | 6 +- docs/subsystems/user-questions.i18n.yaml | 2 +- docs/subsystems/user-questions.zh.md | 2 +- docs/subsystems/web-server.i18n.yaml | 2 +- docs/subsystems/web-server.zh.md | 6 +- docs/subsystems/web.i18n.yaml | 2 +- docs/subsystems/web.zh.md | 6 +- docs/subsystems/workflow.i18n.yaml | 2 +- docs/subsystems/workflow.zh.md | 12 +- docs/subsystems/workspace.i18n.yaml | 2 +- docs/subsystems/workspace.zh.md | 12 +- docs/testing.i18n.yaml | 2 +- docs/testing.zh.md | 12 +- docs/tool-catalog.i18n.yaml | 2 +- docs/tool-catalog.zh.md | 6 +- docs/user/develop/basic/config.i18n.yaml | 2 +- docs/user/develop/basic/config.zh.md | 8 +- docs/user/develop/basic/index.i18n.yaml | 2 +- docs/user/develop/basic/index.zh.md | 10 +- docs/user/develop/basic/publish.i18n.yaml | 2 +- docs/user/develop/basic/publish.zh.md | 10 +- docs/user/develop/basic/tool.i18n.yaml | 2 +- docs/user/develop/basic/tool.zh.md | 8 +- docs/user/develop/framework/events.i18n.yaml | 2 +- docs/user/develop/framework/events.zh.md | 6 +- docs/user/develop/framework/index.i18n.yaml | 2 +- docs/user/develop/framework/index.zh.md | 6 +- docs/user/develop/framework/service.i18n.yaml | 2 +- docs/user/develop/framework/service.zh.md | 6 +- docs/user/develop/practice/index.i18n.yaml | 2 +- docs/user/develop/practice/index.zh.md | 6 +- docs/user/guide/index.i18n.yaml | 2 +- docs/user/guide/index.zh.md | 12 +- docs/user/guide/providers.i18n.yaml | 2 +- docs/user/guide/providers.zh.md | 4 +- docs/user/guide/python-sdk.i18n.yaml | 2 +- docs/user/guide/python-sdk.zh.md | 4 +- docs/web-styling.i18n.yaml | 2 +- docs/web-styling.zh.md | 4 +- examples/README.i18n.yaml | 2 +- examples/README.zh.md | 12 +- examples/acp-agent/README.i18n.yaml | 2 +- examples/acp-agent/README.zh.md | 4 +- examples/headless-agent/README.i18n.yaml | 2 +- examples/headless-agent/README.zh.md | 2 +- examples/jsonrpc-agent/README.i18n.yaml | 2 +- examples/jsonrpc-agent/README.zh.md | 2 +- examples/mcp-memory/README.i18n.yaml | 2 +- examples/mcp-memory/README.zh.md | 2 +- examples/web-cordis/README.i18n.yaml | 2 +- examples/web-cordis/README.zh.md | 4 +- native/README.i18n.yaml | 2 +- native/README.zh.md | 2 +- packages/README.i18n.yaml | 2 +- packages/README.zh.md | 102 ++--- packages/acp/README.i18n.yaml | 2 +- packages/acp/README.zh.md | 6 +- packages/acp/acp/README.i18n.yaml | 2 +- packages/acp/acp/README.zh.md | 6 +- packages/api/README.i18n.yaml | 2 +- packages/api/README.zh.md | 8 +- packages/api/gateway/README.i18n.yaml | 2 +- packages/api/gateway/README.zh.md | 2 +- packages/boot/README.i18n.yaml | 2 +- packages/boot/README.zh.md | 4 +- packages/boot/app-boot/README.i18n.yaml | 2 +- packages/boot/app-boot/README.zh.md | 8 +- packages/bundle/README.i18n.yaml | 2 +- packages/bundle/README.zh.md | 8 +- packages/bundle/base/README.i18n.yaml | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/headless/README.i18n.yaml | 2 +- packages/bundle/headless/README.zh.md | 4 +- packages/bundle/web-app/README.i18n.yaml | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/client/README.i18n.yaml | 2 +- packages/client/README.zh.md | 76 ++-- packages/client/connection/README.i18n.yaml | 2 +- packages/client/connection/README.zh.md | 4 +- packages/client/locale/README.i18n.yaml | 2 +- packages/client/locale/README.zh.md | 2 +- packages/client/runtime/README.i18n.yaml | 2 +- packages/client/runtime/README.zh.md | 10 +- .../client/ui-agent-preset/README.i18n.yaml | 2 +- packages/client/ui-agent-preset/README.zh.md | 8 +- packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 2 +- packages/client/ui-commands/README.zh.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 2 +- packages/client/ui-conversation/README.zh.md | 24 +- .../client/ui-deliverables/README.i18n.yaml | 2 +- packages/client/ui-deliverables/README.zh.md | 4 +- .../README.i18n.yaml | 2 +- .../ui-directory-picker-browse/README.zh.md | 2 +- .../README.i18n.yaml | 2 +- .../ui-directory-picker-native/README.zh.md | 2 +- packages/client/ui-jobs/README.i18n.yaml | 2 +- packages/client/ui-jobs/README.zh.md | 8 +- packages/client/ui-plan/README.i18n.yaml | 2 +- packages/client/ui-plan/README.zh.md | 2 +- .../client/ui-primitives/README.i18n.yaml | 2 +- packages/client/ui-primitives/README.zh.md | 14 +- packages/client/ui-renderer/README.i18n.yaml | 2 +- packages/client/ui-renderer/README.zh.md | 2 +- .../ui-settings-models/README.i18n.yaml | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../README.i18n.yaml | 2 +- .../ui-settings-plugin-inventory/README.zh.md | 2 +- .../ui-settings-plugins/README.i18n.yaml | 2 +- .../client/ui-settings-plugins/README.zh.md | 2 + packages/client/ui-sidebar/README.i18n.yaml | 2 +- packages/client/ui-sidebar/README.zh.md | 8 +- packages/client/ui-skill/README.i18n.yaml | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-slots/README.i18n.yaml | 2 +- packages/client/ui-slots/README.zh.md | 2 +- packages/client/ui-subagent/README.i18n.yaml | 2 +- packages/client/ui-subagent/README.zh.md | 2 +- packages/client/ui-theme/README.i18n.yaml | 2 +- packages/client/ui-theme/README.zh.md | 6 +- packages/client/ui-tool/README.i18n.yaml | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../client/ui-workflow-run/README.i18n.yaml | 2 +- packages/client/ui-workflow-run/README.zh.md | 2 +- packages/client/ui-workspace/README.i18n.yaml | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- packages/client/web/README.i18n.yaml | 2 +- packages/client/web/README.zh.md | 2 +- packages/code-runtime/README.i18n.yaml | 2 +- packages/code-runtime/README.zh.md | 8 +- .../code-runtime-python/README.i18n.yaml | 2 +- .../code-runtime-python/README.zh.md | 4 +- .../README.i18n.yaml | 2 +- .../code-runtime-worker-thread/README.zh.md | 6 +- .../code-runtime/README.i18n.yaml | 2 +- .../code-runtime/code-runtime/README.zh.md | 4 +- packages/compaction/README.i18n.yaml | 2 +- packages/compaction/README.zh.md | 14 +- .../command-compact/README.i18n.yaml | 2 +- .../compaction/command-compact/README.zh.md | 2 +- .../compaction-basic/README.i18n.yaml | 2 +- .../compaction/compaction-basic/README.zh.md | 8 +- .../README.i18n.yaml | 2 +- .../README.zh.md | 2 +- .../compaction/compaction/README.i18n.yaml | 2 +- packages/compaction/compaction/README.zh.md | 12 +- packages/context/README.i18n.yaml | 2 +- packages/context/README.zh.md | 10 +- .../agent-instructions/README.i18n.yaml | 2 +- .../context/agent-instructions/README.zh.md | 2 + .../context/time-context/README.i18n.yaml | 2 +- packages/context/time-context/README.zh.md | 2 +- .../context/tmux-context/README.i18n.yaml | 2 +- packages/context/tmux-context/README.zh.md | 2 +- packages/core/README.i18n.yaml | 2 +- packages/core/README.zh.md | 18 +- packages/core/agent-loop/README.i18n.yaml | 2 +- packages/core/agent-loop/README.zh.md | 12 +- .../agent-tool-presentation/README.i18n.yaml | 2 +- .../core/agent-tool-presentation/README.zh.md | 10 +- packages/core/agent/README.i18n.yaml | 2 +- packages/core/agent/README.zh.md | 16 +- packages/core/scope/README.i18n.yaml | 2 +- packages/core/scope/README.zh.md | 4 +- packages/core/session/README.i18n.yaml | 2 +- packages/core/session/README.zh.md | 18 +- packages/core/system-prompt/README.i18n.yaml | 2 +- packages/core/system-prompt/README.zh.md | 8 +- packages/core/tools/README.i18n.yaml | 2 +- packages/core/tools/README.zh.md | 32 +- packages/credentials/README.i18n.yaml | 2 +- packages/credentials/README.zh.md | 6 +- .../credentials-local/README.i18n.yaml | 2 +- .../credentials-local/README.zh.md | 8 +- .../credentials/credentials/README.i18n.yaml | 2 +- packages/credentials/credentials/README.zh.md | 2 +- packages/e2b/README.i18n.yaml | 2 +- packages/e2b/README.zh.md | 10 +- packages/e2b/e2b/README.i18n.yaml | 2 +- packages/e2b/e2b/README.zh.md | 2 +- packages/e2b/fs-e2b/README.i18n.yaml | 2 +- packages/e2b/fs-e2b/README.zh.md | 4 +- packages/e2b/subprocess-e2b/README.i18n.yaml | 2 +- packages/e2b/subprocess-e2b/README.zh.md | 2 +- packages/examples/README.i18n.yaml | 2 +- packages/examples/README.zh.md | 8 +- packages/examples/acp-demo/README.i18n.yaml | 2 +- packages/examples/acp-demo/README.zh.md | 2 +- .../agent-spine-demo/README.i18n.yaml | 2 +- .../examples/agent-spine-demo/README.zh.md | 4 +- .../examples/jsonrpc-demo/README.i18n.yaml | 2 +- packages/examples/jsonrpc-demo/README.zh.md | 6 +- packages/experimental/team/README.i18n.yaml | 2 +- packages/experimental/team/README.zh.md | 2 +- .../experimental/tool-team/README.i18n.yaml | 2 +- packages/experimental/tool-team/README.zh.md | 4 +- packages/extensions/README.i18n.yaml | 2 +- packages/extensions/README.zh.md | 10 +- .../cordis-host-runner/README.i18n.yaml | 2 +- .../cordis-host-runner/README.zh.md | 12 +- .../extensions/tool-cordis/README.i18n.yaml | 2 +- packages/extensions/tool-cordis/README.zh.md | 14 +- .../command-feedback/README.i18n.yaml | 2 +- .../feedback/command-feedback/README.zh.md | 6 +- .../message-feedback/README.i18n.yaml | 2 +- .../feedback/message-feedback/README.zh.md | 2 +- packages/fs/README.i18n.yaml | 2 +- packages/fs/README.zh.md | 10 +- packages/fs/fs-local/README.i18n.yaml | 2 +- packages/fs/fs-local/README.zh.md | 8 +- .../fs/fs-observation-policy/README.i18n.yaml | 2 +- .../fs/fs-observation-policy/README.zh.md | 4 +- packages/fs/fs-sandbox/README.i18n.yaml | 2 +- packages/fs/fs-sandbox/README.zh.md | 10 +- packages/fs/fs/README.i18n.yaml | 2 +- packages/fs/fs/README.zh.md | 12 +- packages/fs/tool-fs-search/README.i18n.yaml | 2 +- packages/fs/tool-fs-search/README.zh.md | 4 +- packages/fs/tool-fs/README.i18n.yaml | 2 +- packages/fs/tool-fs/README.zh.md | 10 +- .../tool-str-replace-editor/README.i18n.yaml | 2 +- .../fs/tool-str-replace-editor/README.zh.md | 2 +- packages/goal/README.i18n.yaml | 2 +- packages/goal/README.zh.md | 10 +- packages/goal/command-goal/README.i18n.yaml | 2 +- packages/goal/command-goal/README.zh.md | 2 +- .../goal/goal-round-driver/README.i18n.yaml | 2 +- packages/goal/goal-round-driver/README.zh.md | 4 +- packages/goal/goal/README.i18n.yaml | 2 +- packages/goal/goal/README.zh.md | 4 +- packages/goal/tool-goal/README.i18n.yaml | 2 +- packages/goal/tool-goal/README.zh.md | 6 +- packages/guard/README.i18n.yaml | 2 +- packages/guard/README.zh.md | 6 +- .../repeat-tool-reminder/README.i18n.yaml | 2 +- .../guard/repeat-tool-reminder/README.zh.md | 2 +- .../guard/timeout-policy/README.i18n.yaml | 2 +- packages/guard/timeout-policy/README.zh.md | 2 +- packages/hooks/README.i18n.yaml | 2 +- packages/hooks/README.zh.md | 8 +- packages/hooks/hook-protocol/README.i18n.yaml | 2 +- packages/hooks/hook-protocol/README.zh.md | 6 +- .../hooks/hooks-claude-code/README.i18n.yaml | 2 +- packages/hooks/hooks-claude-code/README.zh.md | 6 +- packages/hooks/hooks-codex/README.i18n.yaml | 2 +- packages/hooks/hooks-codex/README.zh.md | 4 +- packages/host/README.i18n.yaml | 2 +- packages/host/README.zh.md | 22 +- packages/host/apiproxy/README.i18n.yaml | 2 +- packages/host/apiproxy/README.zh.md | 16 +- .../directory-picker-auto/README.i18n.yaml | 2 +- .../host/directory-picker-auto/README.zh.md | 2 +- .../directory-picker-browse/README.i18n.yaml | 2 +- .../host/directory-picker-browse/README.zh.md | 6 +- .../directory-picker-native/README.i18n.yaml | 2 +- .../host/directory-picker-native/README.zh.md | 4 +- .../host/directory-picker/README.i18n.yaml | 2 +- packages/host/directory-picker/README.zh.md | 4 +- .../host/frontend-static/README.i18n.yaml | 2 +- packages/host/frontend-static/README.zh.md | 2 +- .../host/plugin-inventory/README.i18n.yaml | 2 +- packages/host/plugin-inventory/README.zh.md | 2 +- packages/host/webserver/README.i18n.yaml | 2 +- packages/host/webserver/README.zh.md | 2 +- packages/identity/README.i18n.yaml | 2 +- packages/identity/README.zh.md | 2 +- packages/interaction/README.i18n.yaml | 2 +- packages/interaction/README.zh.md | 14 +- .../interaction/commands/README.i18n.yaml | 2 +- packages/interaction/commands/README.zh.md | 4 +- .../permission-presets/README.i18n.yaml | 2 +- .../permission-presets/README.zh.md | 2 +- .../tool-ask-user/README.i18n.yaml | 2 +- .../interaction/tool-ask-user/README.zh.md | 2 +- .../user-approval/README.i18n.yaml | 2 +- .../interaction/user-approval/README.zh.md | 4 +- packages/jobs/README.i18n.yaml | 2 +- packages/jobs/README.zh.md | 10 +- packages/jobs/jobs-local/README.i18n.yaml | 2 +- packages/jobs/jobs-local/README.zh.md | 4 +- packages/jobs/jobs/README.i18n.yaml | 2 +- packages/jobs/jobs/README.zh.md | 6 +- packages/jobs/tool-jobs/README.i18n.yaml | 2 +- packages/jobs/tool-jobs/README.zh.md | 2 +- packages/llm/README.i18n.yaml | 2 +- packages/llm/README.zh.md | 14 +- packages/llm/llm-deepseek/README.i18n.yaml | 2 +- packages/llm/llm-deepseek/README.zh.md | 4 +- packages/llm/llm-pi-ai/README.i18n.yaml | 2 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm/README.i18n.yaml | 2 +- packages/llm/llm/README.zh.md | 12 +- packages/llm/token-meter/README.i18n.yaml | 2 +- packages/llm/token-meter/README.zh.md | 2 +- packages/lsp/README.i18n.yaml | 2 +- packages/lsp/README.zh.md | 4 +- packages/lsp/lsp/README.i18n.yaml | 2 +- packages/lsp/lsp/README.zh.md | 2 +- packages/lsp/tool-lsp/README.i18n.yaml | 2 +- packages/lsp/tool-lsp/README.zh.md | 4 +- packages/mcp/README.i18n.yaml | 2 +- packages/mcp/README.zh.md | 2 +- packages/plan/README.i18n.yaml | 2 +- packages/plan/README.zh.md | 6 +- packages/plan/plan-mode/README.i18n.yaml | 2 +- packages/plan/plan-mode/README.zh.md | 10 +- packages/preset/README.i18n.yaml | 2 +- packages/preset/README.zh.md | 2 +- .../preset/agent-presets/README.i18n.yaml | 2 +- packages/preset/agent-presets/README.zh.md | 8 +- packages/preset/persona/README.i18n.yaml | 2 +- packages/preset/persona/README.zh.md | 2 +- packages/sandbox/README.i18n.yaml | 2 +- packages/sandbox/README.zh.md | 10 +- .../sandbox/sandbox-local/README.i18n.yaml | 2 +- packages/sandbox/sandbox-local/README.zh.md | 6 +- .../sandbox/sandbox-policy/README.i18n.yaml | 2 +- packages/sandbox/sandbox-policy/README.zh.md | 4 +- .../sandbox-windows-acl/README.i18n.yaml | 2 +- .../sandbox/sandbox-windows-acl/README.zh.md | 4 +- packages/sandbox/sandbox/README.i18n.yaml | 2 +- packages/sandbox/sandbox/README.zh.md | 10 +- packages/schedule/README.i18n.yaml | 2 +- packages/schedule/README.zh.md | 2 +- packages/schedule/schedule/README.i18n.yaml | 2 +- packages/schedule/schedule/README.zh.md | 2 +- packages/sdk/README.i18n.yaml | 2 +- packages/sdk/README.zh.md | 8 +- packages/sdk/client/README.i18n.yaml | 2 +- packages/sdk/client/README.zh.md | 8 +- packages/sdk/protocol/README.i18n.yaml | 2 +- packages/sdk/protocol/README.zh.md | 6 +- packages/sdk/server/README.i18n.yaml | 2 +- packages/sdk/server/README.zh.md | 2 +- packages/session-query/README.i18n.yaml | 2 +- packages/session-query/README.zh.md | 10 +- .../session-log-export/README.i18n.yaml | 2 +- .../session-log-export/README.zh.md | 2 +- .../session-query/README.i18n.yaml | 2 +- .../session-query/session-query/README.zh.md | 4 +- .../tool-session-query/README.i18n.yaml | 2 +- .../tool-session-query/README.zh.md | 2 +- packages/session/README.i18n.yaml | 2 +- packages/session/README.zh.md | 32 +- .../README.i18n.yaml | 2 +- .../session-persistence-jsonl/README.zh.md | 6 +- .../README.i18n.yaml | 2 +- .../session-persistence-sqlite/README.zh.md | 6 +- .../session-persistence/README.i18n.yaml | 2 +- .../session/session-persistence/README.zh.md | 8 +- .../session-projection-cache/README.i18n.yaml | 2 +- .../session-projection-cache/README.zh.md | 2 +- .../session-projection/README.i18n.yaml | 2 +- .../session/session-projection/README.zh.md | 2 +- .../session-telemetry-otel/README.i18n.yaml | 2 +- .../session-telemetry-otel/README.zh.md | 4 +- .../session-telemetry/README.i18n.yaml | 2 +- .../session/session-telemetry/README.zh.md | 6 +- .../README.i18n.yaml | 2 +- .../README.zh.md | 2 +- .../README.i18n.yaml | 2 +- .../README.zh.md | 2 +- .../session-title-llm/README.i18n.yaml | 2 +- .../session/session-title-llm/README.zh.md | 2 + .../session/session-title/README.i18n.yaml | 2 +- packages/session/session-title/README.zh.md | 2 +- packages/settings/README.i18n.yaml | 2 +- packages/settings/README.zh.md | 6 +- packages/shell/README.i18n.yaml | 2 +- packages/shell/README.zh.md | 16 +- packages/shell/bash-local/README.i18n.yaml | 2 +- packages/shell/bash-local/README.zh.md | 16 +- packages/shell/bash-sandbox/README.i18n.yaml | 2 +- packages/shell/bash-sandbox/README.zh.md | 4 +- packages/shell/pwsh-local/README.i18n.yaml | 2 +- packages/shell/pwsh-local/README.zh.md | 12 +- packages/shell/pwsh-sandbox/README.i18n.yaml | 2 +- packages/shell/pwsh-sandbox/README.zh.md | 2 +- packages/shell/shell-env/README.i18n.yaml | 2 +- packages/shell/shell-env/README.zh.md | 2 +- packages/shell/shell/README.i18n.yaml | 2 +- packages/shell/shell/README.zh.md | 8 +- .../tool-bash-persistent/README.i18n.yaml | 2 +- .../shell/tool-bash-persistent/README.zh.md | 2 +- packages/shell/tool-bash/README.i18n.yaml | 2 +- packages/shell/tool-bash/README.zh.md | 18 +- packages/shell/tool-pwsh/README.i18n.yaml | 2 +- packages/shell/tool-pwsh/README.zh.md | 4 +- packages/skill/README.i18n.yaml | 2 +- packages/skill/README.zh.md | 10 +- .../skill/skill-filesystem/README.i18n.yaml | 2 +- packages/skill/skill-filesystem/README.zh.md | 2 +- packages/skill/tool-skill/README.i18n.yaml | 2 +- packages/skill/tool-skill/README.zh.md | 4 +- packages/spill/README.i18n.yaml | 2 +- packages/spill/README.zh.md | 10 +- packages/spill/spill-local/README.i18n.yaml | 2 +- packages/spill/spill-local/README.zh.md | 2 +- packages/spill/spill-policy/README.i18n.yaml | 2 +- packages/spill/spill-policy/README.zh.md | 4 +- packages/spill/spill/README.i18n.yaml | 2 +- packages/spill/spill/README.zh.md | 2 +- packages/storage/README.i18n.yaml | 2 +- packages/storage/README.zh.md | 12 +- .../storage/storage-domain/README.i18n.yaml | 2 +- packages/storage/storage-domain/README.md | 2 +- .../storage/storage-json/README.i18n.yaml | 4 +- packages/storage/storage-json/README.md | 2 +- packages/storage/storage-json/README.zh.md | 2 +- .../storage/storage-sqlite/README.i18n.yaml | 4 +- packages/storage/storage-sqlite/README.md | 2 +- packages/storage/storage-sqlite/README.zh.md | 2 +- packages/storage/storage/README.i18n.yaml | 2 +- packages/storage/storage/README.zh.md | 2 +- packages/subagent/README.i18n.yaml | 2 +- packages/subagent/README.zh.md | 26 +- .../subagent/subagent-acp/README.i18n.yaml | 2 +- packages/subagent/subagent-acp/README.zh.md | 10 +- .../subagent-claude-code/README.i18n.yaml | 2 +- .../subagent-claude-code/README.zh.md | 6 +- .../subagent/subagent-codex/README.i18n.yaml | 2 +- packages/subagent/subagent-codex/README.zh.md | 6 +- .../subagent-dsh-sdk/README.i18n.yaml | 2 +- .../subagent/subagent-dsh-sdk/README.zh.md | 10 +- .../subagent-fork-in-process/README.i18n.yaml | 2 +- .../subagent-fork-in-process/README.zh.md | 8 +- .../README.i18n.yaml | 2 +- .../subagent-in-process-driver/README.zh.md | 4 +- .../README.i18n.yaml | 2 +- .../subagent-spawn-in-process/README.zh.md | 2 +- packages/subagent/subagent/README.i18n.yaml | 2 +- packages/subagent/subagent/README.zh.md | 14 +- .../tool-subagent-control/README.i18n.yaml | 2 +- .../tool-subagent-control/README.zh.md | 4 +- .../tool-subagent-report/README.i18n.yaml | 2 +- .../tool-subagent-report/README.zh.md | 4 +- .../subagent/tool-subagent/README.i18n.yaml | 2 +- packages/subagent/tool-subagent/README.zh.md | 10 +- packages/subprocess/README.i18n.yaml | 2 +- packages/subprocess/README.zh.md | 8 +- .../subprocess-local/README.i18n.yaml | 2 +- .../subprocess/subprocess-local/README.zh.md | 6 +- .../subprocess/subprocess/README.i18n.yaml | 2 +- packages/subprocess/subprocess/README.zh.md | 4 +- packages/terminal/README.i18n.yaml | 2 +- packages/terminal/README.zh.md | 6 +- .../terminal/tool-terminal/README.i18n.yaml | 2 +- packages/terminal/tool-terminal/README.zh.md | 2 +- packages/test-support/README.i18n.yaml | 2 +- packages/test-support/README.zh.md | 14 +- .../acp-snapshot/README.i18n.yaml | 2 +- .../test-support/acp-snapshot/README.zh.md | 10 +- .../test-support/llm-replay/README.i18n.yaml | 2 +- packages/test-support/llm-replay/README.zh.md | 2 +- packages/todo/README.i18n.yaml | 2 +- packages/todo/README.zh.md | 4 +- packages/todo/tool-todo/README.i18n.yaml | 2 +- packages/todo/tool-todo/README.zh.md | 8 +- packages/typert/README.i18n.yaml | 2 +- packages/typert/README.zh.md | 6 +- packages/typert/loader/README.i18n.yaml | 2 +- packages/typert/loader/README.zh.md | 2 +- packages/typert/registry/README.i18n.yaml | 2 +- packages/typert/registry/README.zh.md | 4 +- packages/util/README.i18n.yaml | 2 +- packages/util/README.zh.md | 12 +- .../util/launch-environment/README.i18n.yaml | 2 +- packages/util/launch-environment/README.zh.md | 2 +- packages/util/native-command/README.i18n.yaml | 2 +- packages/util/native-command/README.zh.md | 2 +- .../util/output-retention/README.i18n.yaml | 2 +- packages/util/output-retention/README.zh.md | 2 +- packages/util/timeout/README.i18n.yaml | 2 +- packages/util/timeout/README.zh.md | 4 +- packages/web/README.i18n.yaml | 2 +- packages/web/README.zh.md | 16 +- packages/web/tool-web/README.i18n.yaml | 2 +- packages/web/tool-web/README.zh.md | 10 +- packages/web/web-fetch-http/README.i18n.yaml | 2 +- packages/web/web-fetch-http/README.zh.md | 8 +- .../web/web-search-deepseek/README.i18n.yaml | 2 +- packages/web/web-search-deepseek/README.zh.md | 4 +- packages/web/web-search-exa/README.i18n.yaml | 2 +- packages/web/web-search-exa/README.zh.md | 6 +- .../web-search-perplexity/README.i18n.yaml | 2 +- .../web/web-search-perplexity/README.zh.md | 6 +- packages/web/web/README.i18n.yaml | 2 +- packages/web/web/README.zh.md | 4 +- packages/workflow/README.i18n.yaml | 2 +- packages/workflow/README.zh.md | 12 +- packages/workflow/tool-ralph/README.i18n.yaml | 2 +- packages/workflow/tool-ralph/README.zh.md | 4 +- .../workflow/tool-workflow/README.i18n.yaml | 2 +- packages/workflow/tool-workflow/README.zh.md | 8 +- .../workflow-worker-thread/README.i18n.yaml | 2 +- .../workflow-worker-thread/README.zh.md | 2 +- packages/workflow/workflow/README.i18n.yaml | 2 +- packages/workflow/workflow/README.zh.md | 2 +- packages/workspace/README.i18n.yaml | 2 +- packages/workspace/README.zh.md | 6 +- packages/workspace/workspace/README.i18n.yaml | 2 +- packages/workspace/workspace/README.zh.md | 4 +- python/README.i18n.yaml | 2 +- python/README.zh.md | 8 +- python/development.i18n.yaml | 2 +- python/development.zh.md | 4 +- scripts/gen-cordis-catalog-record.spec.ts | 24 +- scripts/gen-cordis-catalog.ts | 16 +- scripts/gen-translation-brief.ts | 15 +- scripts/project-doc-site.spec.ts | 24 +- .../request-response.expected.json | 18 +- scripts/translation-brief.spec.ts | 2 + scripts/translation-brief.ts | 4 +- scripts/translation-links.spec.ts | 193 +++++++++ scripts/translation-links.ts | 375 ++++++++++++++++++ scripts/translation-pairing-git.ts | 10 + scripts/translation-pairing-merge.spec.ts | 38 ++ scripts/translation-pairing-merge.ts | 38 +- scripts/translation-pairing.spec.ts | 76 +++- scripts/translation-pairing.ts | 27 +- scripts/translation-prompt.spec.ts | 2 + scripts/verify-translation-pairing.ts | 91 ++++- 1519 files changed, 4137 insertions(+), 3108 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-18-localized-bilingual-links.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-18-localized-bilingual-links.md create mode 100644 .agents/notes/implemented/process/2026-08-18-localized-bilingual-links.zh.md create mode 100644 scripts/translation-links.spec.ts create mode 100644 scripts/translation-links.ts diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index f92ea2f66d..faf4d69b5b 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.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/README.md README.md: ae8e4724d610c97d74910d7dec5c95af69e93281 -README.zh.md: a16403ae87cd16d758b8303f42f60ad34b9c1eb3 +README.zh.md: 8cecc7068d9efc5e2ebb78d2301d03633f70f137 diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index a16403ae87..8cecc7068d 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -16,13 +16,13 @@ 文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 -活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 +活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 ## 分类 -每份 Agent Note 属于 `scripts/agent-note-tree.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。新增类别需要同时更新规范集合与本节。见[分类 Agent Note](implemented/process/2026-06-20-agent-note-classification.md)。 +每份 Agent Note 属于 `scripts/agent-note-tree.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。新增类别需要同时更新规范集合与本节。见[分类 Agent Note](implemented/process/2026-06-20-agent-note-classification.zh.md)。 | 类别 | 覆盖范围 | |---|---| @@ -41,7 +41,9 @@ 归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 -封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) 记录了设计依据。 +封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。 + + ## 何时需要写一份 @@ -57,7 +59,7 @@ ## 文件格式 -每份活跃 Agent Note 遵循统一的文件内格式,由 `pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts),`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.md)。归档记录保留封存时的格式,并增加上述归档日期行。 +每份活跃 Agent Note 遵循统一的文件内格式,由 `pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts),`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.zh.md)。归档记录保留封存时的格式,并增加上述归档日期行。 ### 头部块 @@ -126,4 +128,4 @@ Status: ### 中文对侧文件 -`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.md)逐章节与其英文对侧文件保持相同结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件;配对门禁检查它们的一致性。 +`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.zh.md)逐章节与其英文对侧文件保持相同结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件;配对门禁检查它们的一致性。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index 0b150d8090..f31dee1c9d 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md 2026-06-11-content-block-vocabulary.md: 5228724bb9101307db9929aaf7831b477c2a6022 -2026-06-11-content-block-vocabulary.zh.md: b43de335917c6b5304a94b296fcb8253827b0600 +2026-06-11-content-block-vocabulary.zh.md: b88fa60f585c5e479f76c27dd561374b7c2a3197 diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index b43de33591..b88fa60f58 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -12,7 +12,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 自主拥有词汇:消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。 -会话内上下文注入(`context/message`)和轮次中途 steering(中途引导)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。 +会话内上下文注入(`context/message`)和轮次中途 steering(中途引导)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。 ## 曾考虑的替代方案 @@ -22,7 +22,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 ## 后果 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。 -- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。 -- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。 +- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.zh.md)。 +- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md)与[无端到端可用路径的请求旋钮](../../archived/simplification/2026-07-04-drop-inert-request-knobs.zh.md) Agent Note。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 - 跨包边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index e5c589dbf9..44272f7ca1 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md 2026-06-11-dev-invariants-over-deep-readonly.md: 66980f1ee09c6112f72786d6c3a147aadbc57f6c -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 0d694ea84e1b7c265491e9adb64757378b4185f1 +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 576e53e0b27e65f6fa071ff649509223a7bc30ff diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index 0d694ea84e..576e53e0b2 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -32,7 +32,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 ### 包拥有的不变式配套插件检查关系 -`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.md))。 +`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.zh.md))。 当会话配套插件附加到已有会话或以种子记录初始化的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml index 760c3fd099..868d215253 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md 2026-06-11-event-sourced-sessions.md: b6d17d2db1d9b489f3d4224683b61e62be485014 -2026-06-11-event-sourced-sessions.zh.md: a78349ea2385b3c9d32870757bb607f15996196d +2026-06-11-event-sourced-sessions.zh.md: 11e5740dc2636fc002ed7158167b1dfbb39a971d diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md index a78349ea23..11e5740dc2 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md @@ -24,5 +24,5 @@ MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严 - 回放、追踪与遥测在结构上得到保证,而非事后附加。 - 持久化仍是插件关注点;内存存储随 dsh-session 一起提供。 -- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.md)在日志具备持久性后固定了其结构。 +- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.zh.md)在日志具备持久性后固定了其结构。 - 派生成本随日志长度增长,压缩(dsh-compaction)是预期的缓解手段,而不是改写日志。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml index 8e97d86ccf..57843018c6 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md 2026-06-11-microkernel-event-taxonomy.md: 6cacbc30f29c863de0f8ddfa4df7251a64bc80ca -2026-06-11-microkernel-event-taxonomy.zh.md: 2e43b57d62d8fca692ffab379d1841e6a3e61fad +2026-06-11-microkernel-event-taxonomy.zh.md: 1c7919392b835c491b9dc606d46f638a708d2948 diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md index 2e43b57d62..1c7919392b 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md @@ -25,7 +25,7 @@ Status: implemented ## 后果 -- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../../docs/cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持更新)。 +- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../../docs/cookbook/extension-cookbook.zh.md#the-feature--mechanism-map)是证明义务,保持更新)。 - HMR 与 dispose 无需额外工作:监听器和注册均为 Cordis effect。 - waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。 - 循环必须具备防御性:插件异常在轮次级别被隔离,来自任何扩展点的 steering(中途引导)永远不会被搁置(有回归测试保障)。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml index 2e6f1ad2df..821a220d9a 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md 2026-06-11-runtime-arg-validation.md: 520823c76f147205805d306e97b5461f38b84c35 -2026-06-11-runtime-arg-validation.zh.md: 0a6ef40c93ec3550d6e991836950be849e86ede7 +2026-06-11-runtime-arg-validation.zh.md: bc2f743300be78cc2a01f027d5aca81fcb18b468 diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md index 0a6ef40c93..bc2f743300 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`defineTool`([统一 schema DSL](2026-07-20-unified-json-value-schema-dsl.md))为工具作者的 `execute(args)` 提供了经 `InferArgs` 映射的类型化参数。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON:没有任何机制强制模型遵守 schema,因此畸形调用(缺少必需键、声明为数字的位置传入字符串,或字面量超出声明的集合)会以「仅在名义上类型化」的状态到达 `execute`。工具函数体随后要么在处理结构错误的数据时崩溃,要么在不报错的情况下行为异常。 +`defineTool`([统一 schema DSL](2026-07-20-unified-json-value-schema-dsl.zh.md))为工具作者的 `execute(args)` 提供了经 `InferArgs` 映射的类型化参数。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON:没有任何机制强制模型遵守 schema,因此畸形调用(缺少必需键、声明为数字的位置传入字符串,或字面量超出声明的集合)会以「仅在名义上类型化」的状态到达 `execute`。工具函数体随后要么在处理结构错误的数据时崩溃,要么在不报错的情况下行为异常。 ## 决策 @@ -17,8 +17,8 @@ Status: implemented ## 后果 - 模型会收到有关自身畸形调用的可操作反馈,而不是遭遇不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。 -- 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时通过针对性改坏参数来断言其会被拒绝),通过自动化检查消除这种漂移风险。 -- `ToolArgsError` 是[结构化错误分类体系](2026-06-11-structured-error-taxonomy.md)中 `HarnessError` 的子类,保留其 `code` 字段;读取 `.message` 的调用方不受该层级结构影响。 +- 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.zh.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时通过针对性改坏参数来断言其会被拒绝),通过自动化检查消除这种漂移风险。 +- `ToolArgsError` 是[结构化错误分类体系](2026-06-11-structured-error-taxonomy.zh.md)中 `HarnessError` 的子类,保留其 `code` 字段;读取 `.message` 的调用方不受该层级结构影响。 - 校验开销相对于一次模型调用可忽略不计。 diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml index 106164a71c..b7a0ea7192 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-capability-seams.md 2026-06-13-capability-seams.md: 2a166278ea454895177fa12b58f5493276f19cd1 -2026-06-13-capability-seams.zh.md: 0874af5826960ab9e718eb07b00c12b446edfd78 +2026-06-13-capability-seams.zh.md: 28b45cbbc7f65a0b783db3d91a2e559132b5779f diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md index 0874af5826..28b45cbbc7 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md @@ -26,7 +26,7 @@ Service Provider 与 Consumer 由此独立演进:沙箱化执行器替换 `dsh ## 术语:seam 指三者组合,而非接口 -一个 **seam** 是完整的能力——三个角色合在一起:**Service Definition**(拥有 `ctx.` 和词汇的 Cordis `Service`)、一个或多个 **Service Provider**,以及一个或多个 **Consumer**。`packages/shell` 是规范范例——`dsh-shell` / `dsh-bash-local`+`dsh-bash-sandbox` / `dsh-tool-bash`。一个包可以承担多个角色,但单个角色本身不是 seam。「seam」一词严格保留给这种完整能力;命名其中一个组成部分时,应使用其角色、类、服务、约定或扩展点。[术语表](../../../../docs/glossary.md#capability-seam)是规范条目。 +一个 **seam** 是完整的能力——三个角色合在一起:**Service Definition**(拥有 `ctx.` 和词汇的 Cordis `Service`)、一个或多个 **Service Provider**,以及一个或多个 **Consumer**。`packages/shell` 是规范范例——`dsh-shell` / `dsh-bash-local`+`dsh-bash-sandbox` / `dsh-tool-bash`。一个包可以承担多个角色,但单个角色本身不是 seam。「seam」一词严格保留给这种完整能力;命名其中一个组成部分时,应使用其角色、类、服务、约定或扩展点。[术语表](../../../../docs/glossary.zh.md#capability-seam)是规范条目。 ## 曾考虑的替代方案 @@ -35,4 +35,4 @@ Service Provider 与 Consumer 由此独立演进:沙箱化执行器替换 `dsh ## 后果 -分离角色会增加包和样板代码(`package.json`、`tsconfig`、README 和注入接线)。换来的是:Service Provider 与 Consumer 独立发布和版本管理,新后端永远不会波及面向模型的约定。[AGENTS.md](../../../../AGENTS.md) 和 [architecture.md](../../../../docs/architecture.md) 载有这项规则;bash 三件套是参考模板。本 Agent Note 记录为什么独立变化的角色通常需要拆分,而确实共享的关注点可以保持合并。 +分离角色会增加包和样板代码(`package.json`、`tsconfig`、README 和注入接线)。换来的是:Service Provider 与 Consumer 独立发布和版本管理,新后端永远不会波及面向模型的约定。[AGENTS.md](../../../../AGENTS.md) 和 [architecture.md](../../../../docs/architecture.zh.md) 载有这项规则;bash 三件套是参考模板。本 Agent Note 记录为什么独立变化的角色通常需要拆分,而确实共享的关注点可以保持合并。 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml index d44279eb42..96f8e6834f 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md 2026-06-13-twin-llm-adapters.md: a4c87325a0b0d1ebe6cf8f95672e5de74ef37d57 -2026-06-13-twin-llm-adapters.zh.md: 2af7e7cca4d4c40e18cece1a4dd5c9cdb11901c8 +2026-06-13-twin-llm-adapters.zh.md: 087f300f871b8b3821925daf25bfbf968062b047 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md index 2af7e7cca4..087f300f87 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -`dsh-llm` 拥有一套提供方无关的流式词汇:`StreamChunk` 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.md))。如果词汇仅针对单个适配器定义,就有可能将该适配器的特异行为固化到「中立」约定中:唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——而届时修复这种泄漏的代价已经很高。 +`dsh-llm` 拥有一套提供方无关的流式词汇:`StreamChunk` 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.zh.md))。如果词汇仅针对单个适配器定义,就有可能将该适配器的特异行为固化到「中立」约定中:唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——而届时修复这种泄漏的代价已经很高。 ## 决策 从一开始就针对同一份约定交付**两个**适配器,刻意基于不同的内部实现构建: -- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([已归档的 SSE 解析器替换](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。 +- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([已归档的 SSE 解析器替换](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.zh.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。 - `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。 二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生适配器确立了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。这一分歧正是由基于库的适配器暴露出来的,单一直接 fetch 适配器会将其隐藏。 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index 61f9754431..8f9aa62e49 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md 2026-06-14-session-persistence.md: 62228bd2f5b25b13880a563818d08f3a2d52d956 -2026-06-14-session-persistence.zh.md: b10ceebd95d4d05ae3f7ea620183ad8fffd074ee +2026-06-14-session-persistence.zh.md: ebf004333c383336cd025aa8a4aabc9d1e07f0e5 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index b10ceebd95..ebf004333c 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -8,28 +8,28 @@ Status: implemented 会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、dispose(资源释放)时采用 fire-and-forget 方式排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复、持久 fork 以及宿主侧的会话浏览都无法实现。 -[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——并由同一接口封装。 +[事件溯源模型](2026-06-11-event-sourced-sessions.zh.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——并由同一接口封装。 ## 决策 -持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.md),`dsh-shell` 模板),而非循环或核心逻辑: +持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.zh.md),`dsh-shell` 模板),而非循环或核心逻辑: 1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 -2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md)是默认物理编码,也可通过配置使用原始行。 +2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。 长期有效、存在争议的关键选择: - **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 -- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 +- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 - **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 约定的事务中),读取使用 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上采用的正是这种接口形态),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该约定以相同的语义约束两个后端(惰性物化、逻辑关闭中断轮次、修复只提交一次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值;未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识,以及任何非当前版本文件,都会在修改日志模式之前被拒绝。 -- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header,SQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) -- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 +- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header,SQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。) +- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 ## 曾考虑的替代方案 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 +格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml index 9898684b9e..8bcd0c506e 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md 2026-06-17-filesystem-capability-seam.md: 6265aebf5e7ffdd4ec4dc0083aa55adb34ee78a1 -2026-06-17-filesystem-capability-seam.zh.md: 024421ed59fa1e6c29bcc12ab51f69ac19a0c231 +2026-06-17-filesystem-capability-seam.zh.md: c1f500641b7c9888bae5f16b904bf7f6d1501331 diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md index 024421ed59..c1f500641b 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md @@ -20,7 +20,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-shell` / `dsh-bash-local ## 决策 -文件系统访问是一个一等的能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md): +文件系统访问是一个一等的能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md): 1. `@deepseek-ai/dsh-fs`(`packages/fs/fs`)拥有抽象的 `ctx.fs` 服务、文件系统词汇类型,以及 `fs/*` 策略事件词汇。 2. `@deepseek-ai/dsh-fs-local`(`packages/fs/fs-local`)提供第一个实现,以本地文件系统为后端。 @@ -28,15 +28,15 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-shell` / `dsh-bash-local Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。 -读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-observation-policy`(`packages/fs/fs-observation-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-observation-policy` 以获得读后写/编辑能力。本决策确立了由三个包构成的边界;策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 做出。 +读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-observation-policy`(`packages/fs/fs-observation-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-observation-policy` 以获得读后写/编辑能力。本决策确立了由三个包构成的边界;策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.zh.md) 做出。 第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。 -第一个消费方有意仅限文本文件:`dsh-tool-fs` 暴露面向模型的 `read`、`write` 和 `edit` 工具,处理 UTF-8 文本文件。未来的消费方可以添加目录列表、搜索/glob、二进制安全操作、文件监视或更高层的项目操作,只要 `ctx.fs` 上存在所需能力,就无需改动本地后端包。直接目录列表后来由[为文件系统 seam 添加直接目录列举能力](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)添加。 +第一个消费方有意仅限文本文件:`dsh-tool-fs` 暴露面向模型的 `read`、`write` 和 `edit` 工具,处理 UTF-8 文本文件。未来的消费方可以添加目录列表、搜索/glob、二进制安全操作、文件监视或更高层的项目操作,只要 `ctx.fs` 上存在所需能力,就无需改动本地后端包。直接目录列表后来由[为文件系统 seam 添加直接目录列举能力](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md)添加。 文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但路径包含约束策略是独立的决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。 -读后写/编辑与观测状态属于 `dsh-fs-observation-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.md)和[事件门控插件](2026-06-26-file-context-as-event-gate.md) Agent Note。 +读后写/编辑与观测状态属于 `dsh-fs-observation-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md)和[事件门控插件](2026-06-26-file-context-as-event-gate.zh.md) Agent Note。 ## 包拓扑 @@ -84,7 +84,7 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需 - 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。 - `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。 -即使另一项能力共享提供方的执行环境,`targetKey` 仍保持不透明。这类消费方通过提供方的 `processPath(target)`、`fileUrl(target)` 或 `contains(parent, child)` 获取所需事实;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)说明这些事实为何属于文件系统 seam。 +即使另一项能力共享提供方的执行环境,`targetKey` 仍保持不透明。这类消费方通过提供方的 `processPath(target)`、`fileUrl(target)` 或 `contains(parent, child)` 获取所需事实;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.zh.md)说明这些事实为何属于文件系统 seam。 读取和变更结果必须包含不透明的文件 `version`。本地后端从 bigint stat 元数据(`dev`、`ino`、`size`、`mtimeNs` 和 `ctimeNs`)派生令牌,因此同大小重写和 inode 替换都会可靠地使消费方失效;远程后端可以使用 revision id 或类似 hash 的令牌。`dsh-fs-observation-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。 @@ -98,7 +98,7 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需 策略插件(而非 `ctx.fs`)对先前观测进行门控:`edit` 要求 owner 有先前观测(否则报 `FS_NOT_OBSERVED`),记录的版本作为 CAS 基础传给 `editText`。在策略插件缺席时,`ctx.fs` 本身是一个完整的无约束 seam(无条件写入/编辑);工具从不与策略方法耦合。 -文件系统约定失败以 `FsError extends HarnessError` 抛出,工具注册表将其转换为带结构化 `{ name, code }` 元数据的 `isError` 工具结果。`dsh-fs` 拥有此词汇,而非由每个工具各自发明消息。错误码包括 `FS_NOT_FOUND`、`FS_NOT_TEXT`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_NOT_REGULAR_FILE`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND` 和 `FS_ABORTED`。(早期草案包含 `FS_PARTIAL_OBSERVATION`;基于新鲜度的授权没有 partial/full 区分,因此已删除。目录列表相关的错误码后来由[为文件系统 seam 添加直接目录列举能力](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)添加。) +文件系统约定失败以 `FsError extends HarnessError` 抛出,工具注册表将其转换为带结构化 `{ name, code }` 元数据的 `isError` 工具结果。`dsh-fs` 拥有此词汇,而非由每个工具各自发明消息。错误码包括 `FS_NOT_FOUND`、`FS_NOT_TEXT`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_NOT_REGULAR_FILE`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND` 和 `FS_ABORTED`。(早期草案包含 `FS_PARTIAL_OBSERVATION`;基于新鲜度的授权没有 partial/full 区分,因此已删除。目录列表相关的错误码后来由[为文件系统 seam 添加直接目录列举能力](../../archived/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md)添加。) ## 工具消费方行为 @@ -140,7 +140,9 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需 - **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。 - **单一合并包 `dsh-fs-tools`**:seam 之前的形态;以与 bash 相同的 Service Definition / Service Provider / Consumer 拆分理由否决,且合并名称从未成为公开 API。 -- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 +- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 和 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.zh.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 + + ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml index 0ecda2484f..03a0440ca1 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.md 2026-06-18-agent-lifecycle-and-ownership-contracts.md: 9bc558bfce75892b0ebdb80e9f8735d440cabaf4 -2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: 65e0018c3f2ba56043898050c8e0b49929f8e7e7 +2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: bbd0a7219f902e5ead4d190c58e9a5d4da2131fd diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md index 65e0018c3f..bbd0a7219f 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个所有权 ### 1. 队列感知的 `Agent.cancel(cause?)` -`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在被领取前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。`whenIdle()` 会在取消后达到完全停稳,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.md)规定了当前的 cause、signal 生命周期与协作式结算约定。 +`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在被领取前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。`whenIdle()` 会在取消后达到完全停稳,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.zh.md)规定了当前的 cause、signal 生命周期与协作式结算约定。 ### 2. `AgentHandle` 异步释放器 @@ -43,7 +43,7 @@ bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agen - **公开的 `BashTask.owner` 字段**而非 `ShellExecutor.ownerOf(id)` Service Definition 方法:否决。一条读取路径即可,无需冗余 API。 - **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时并发释放兄弟 effect(`Promise.all`),store 拥有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。 -- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-api.md))。 +- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-api.zh.md))。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 38a665a0fd..fbc5a3371b 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md 2026-06-18-shared-persistence-write-coordinator.md: 93b6cd1bd058499e71948d3909de8e4c076b445e -2026-06-18-shared-persistence-write-coordinator.zh.md: 9e1bc736d4dba5e59b763710976425fa0ae76c30 +2026-06-18-shared-persistence-write-coordinator.zh.md: 3a2f73b76b14e81b021a97660c79fac479fc70a4 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 9e1bc736d4..3a2f73b76b 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -14,7 +14,7 @@ Status: implemented 组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。协调器让非常规后端与继承层级作斗争的风险由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。 -协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.md)定义。 +协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.zh.md)定义。 协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。 @@ -44,4 +44,4 @@ Status: implemented ## 后果 -协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断收尾事件;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.md)定义。新后端只需实现存储原语,而无需复制有界写入生命周期。 +协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断收尾事件;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义。新后端只需实现存储原语,而无需复制有界写入生命周期。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index b7e2678928..ed11425f4b 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-branded-ids.md 2026-06-20-branded-ids.md: 29b258b21240c92e74051339f0939a8e70933099 -2026-06-20-branded-ids.zh.md: 288125764d4b279c79b7469080bb0c9f2efdc709 +2026-06-20-branded-ids.zh.md: 2f960bac3172f1e83161f1af8f4e9d2cc0cda7bd diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md index 288125764d..2f960bac31 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 +harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 **缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 job id 是普通 `string`:`BashTask.id: string`(`packages/shell/shell/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/shell/shell/src/index.ts` 中的 `ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateJobId`、`assertTaskAccess`、`packages/shell/tool-bash/src/index.ts` 中 `job_id` 的 schema 参数)。它由每执行器计数器生成——`packages/shell/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash job id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。它是面向模型的 id(模型会把 `job_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 -bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` 和 `ShellExecSpec.owner: string | undefined`(`packages/shell/shell/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent 共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/shell/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)覆盖的共享 id 别名。 +bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` 和 `ShellExecSpec.owner: string | undefined`(`packages/shell/shell/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent 共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/shell/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)覆盖的共享 id 别名。 **缺口 2:*已经 brand* 的 ID 在边界处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP(Agent Client Protocol)的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。 @@ -64,6 +64,6 @@ export function OwnerToken(id: string): OwnerToken { ## 后果 -- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(Service Definition + Service Provider + Consumer)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。从可观察行为看,这是一项纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 +- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(Service Definition + Service Provider + Consumer)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。从可观察行为看,这是一项纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 - **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的*会话 id 只要仍是格式正确的 string,就和以前一样能通过类型检查器。本决策不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。 - **「在哪里停下」仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本决策倾向于面向模型或用于访问控制的 id。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index 477c6df3ae..7d4ccbd4e1 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md 2026-06-20-generic-long-running-tool-runtime.md: 7db43323dd83f8e99698a7163412c9b33a607dfb -2026-06-20-generic-long-running-tool-runtime.zh.md: efd306cc8d3b82749635dde302af45235c27b431 +2026-06-20-generic-long-running-tool-runtime.zh.md: 040ea6a01e97c9dc1d93e8f3a0fd9c1f4b0f31fc diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index efd306cc8d..040ea6a01e 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -19,13 +19,13 @@ Status: implemented 长时间运行工具是生产方。`dsh-tool-bash` 将 `ShellProcess` 适配为增量输出与进程取消;`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。bash 与 subagent 能力 seam 保持独立,不依赖会话或任务注册表。 -`JobRegistry` 是 `@deepseek-ai/dsh-jobs` 中的 Service Definition;进程内 Service Provider 是 `@deepseek-ai/dsh-jobs-local` 中的 `LocalJobRegistry`(该拆分记录在[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.md)中)。 +`JobRegistry` 是 `@deepseek-ai/dsh-jobs` 中的 Service Definition;进程内 Service Provider 是 `@deepseek-ai/dsh-jobs-local` 中的 `LocalJobRegistry`(该拆分记录在[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.zh.md)中)。 ## 运行时约定 -字面类型见[任务子系统页面](../../../../docs/subsystems/jobs.md)。生产方调用 `ctx.jobs.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 job id 的工作。 +字面类型见[任务子系统页面](../../../../docs/subsystems/jobs.zh.md)。生产方调用 `ctx.jobs.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 job id 的工作。 -进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../bug-fix/2026-08-11-bounded-background-job-admission.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。 +进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../bug-fix/2026-08-11-bounded-background-job-admission.zh.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。 `outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。 @@ -79,7 +79,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访 流式读取共享一个任务作用域内的消费游标,因为所属模型是预期读取方。UI 或多个独立读取方需要单独的非消费式观察 API;共享该游标会让读取方彼此消费对方的输出。 -系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md)负责。 +系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)负责。 当读取或等待交付终止任务、尚在等待的等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job ` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。 @@ -105,7 +105,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She ### 立即抽象任务运行时后端 -当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。 +当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.zh.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。 ### 由消费方负责授权或清理事件 @@ -131,7 +131,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She ## 后果 -bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本约定。 +bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.zh.md)将生产方指向本约定。 单个确切 owner 无法再无限增加进程内由 Task 承载的工作,另一个 owner 也不会消耗它的额度。取消请求会继续占用容量,直到生产方真正释放资源,因此用新工作替换缓慢停止的任务不会突破已配置的实时资源预算。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index f9b830c3b7..6b8ce888ae 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md 2026-06-21-bounded-llm-request-recovery.md: 3a91d77fe9c08fbcc3afdce4dad9a38e288b4723 -2026-06-21-bounded-llm-request-recovery.zh.md: a6b342b19236251e638242c1e9df6b3d6ed557c0 +2026-06-21-bounded-llm-request-recovery.zh.md: 819e7f7bccb720b24a3a78ddac7881cc30832cf7 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index a6b342b192..819e7f7bcc 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -4,11 +4,11 @@ Status: implemented [English](2026-06-21-bounded-llm-request-recovery.md) | 中文 -[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、已关闭步骤的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.md)取代了其中关于抛出错误身份和流 sidecar 的机制。 +[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.zh.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、已关闭步骤的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.zh.md)取代了其中关于抛出错误身份和流 sidecar 的机制。 ## 问题 -提供方适配器可能在分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束。最终适配器边界会在 `dsh-agent-loop` 接收前把抛出值规范化为该终止 finish 协议;middleware 与结果处理缺陷仍会抛出。loop 会将终止模型请求失败交给 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回约定。 +提供方适配器可能在分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束。最终适配器边界会在 `dsh-agent-loop` 接收前把抛出值规范化为该终止 finish 协议;middleware 与结果处理缺陷仍会抛出。loop 会将终止模型请求失败交给 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.zh.md)规定这一返回约定。 该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号轮次。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 @@ -46,7 +46,7 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen 适配器会先提取结构化事实,再回退到消息检查。它们会验证 HTTP 状态,将 `Retry-After` 的秒数或日期解析为正的有限毫秒延迟,在提供方公开请求 id 时将其品牌化,并区分自身超时与调用方中止。提供方专用 code 和消息可以细化映射,但恢复监听器不会解析它们。 -共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。 +共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。 ### 将重试策略放在现有失败步骤扩展点上 @@ -54,7 +54,7 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen `agent/request-error` waterfall 携带当前 `LlmFailure`、在连续恢复序列中授权重试轮次的不可变先前失败列表,以及提供服务的注册项所携带的不可变重试策略。循环只传递而不解释该策略;它拥有连续失败历史,并在模型请求成功后清除。`dsh-llm-retry` 的 normal 策略统计由同一项确切提供方策略安排的持久重试记录,`dsh-compaction-basic` 则维护自己的上下文溢出预算。因此,暂时性失败与上下文溢出交替出现时,会各自独立消耗其有限预算;最大请求数等于 1 加上所有已加载有限预算之和。 -当前配置形状由[提供方策略决策](../feature/2026-07-24-provider-retry-policies.md)规定。提供方适配器会注册嵌套的 `retryPolicy`;省略时使用 normal 默认值:两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。 +当前配置形状由[提供方策略决策](../feature/2026-07-24-provider-retry-policies.zh.md)规定。提供方适配器会注册嵌套的 `retryPolicy`;省略时使用 normal 默认值:两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。 对于预算未耗尽的合格失败,从 1 开始的暂时性重试计数使用有界指数退避。有效的 `providerRetryAfterMs` 只有在不超过 `maxDelayMs` 时才会取代指数退避;提供方延迟更长时,系统会委托给下一监听器,而不会违反提供方指令提前重试。本地退避乘以 `[1 - jitterRatio, 1 + jitterRatio]` 内的注入随机因子,并将最终值限制到 `maxDelayMs`;提供方延迟不加抖动。 @@ -100,7 +100,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - **向 `dsh-llm` 增加响应开始、中断、丢弃、失败和提交事件**:拒绝采用,因为 agent 日志已经分隔原始分片、成功消息和编号尝试。第二套状态机会重复归属关系,又不能支持有界的同路由重试。 - **增加逻辑路由、能力矩阵和故障转移选择**:拒绝采用,因为当前请求已经显式指定提供方和模型,每个提供方由一个适配器负责,而且没有当前消费方要求自动回退或能够证明语义兼容性。 - **把 `retryable` 或 `failover` 放在 `LlmFailure` 上**:拒绝采用,因为适配器报告事实,部署策略决定动作。同一个 429 可以在交互式组合包中重试,也可以在成本受限的批处理中被拒绝。 -- **只要调用方仍处于活跃状态就无限重试**:[按提供方配置的策略](../feature/2026-07-24-provider-retry-policies.md)对显式 `always` 配置项推翻了这项拒绝,同时保留有界的 normal mode 作为默认值。 +- **只要调用方仍处于活跃状态就无限重试**:[按提供方配置的策略](../feature/2026-07-24-provider-retry-policies.zh.md)对显式 `always` 配置项推翻了这项拒绝,同时保留有界的 normal mode 作为默认值。 - **只通过进程 logger 记录重试状态**:拒绝采用,因为进程日志无法重建会话行为,也不能驱动回放后的 UI 状态。 - **只保留扁平 code**:拒绝采用,因为重试延迟和提供方请求 id 是结构化的提供方事实,而当不同协议失败共用一个稳定 code 时,诊断还需要 HTTP 状态。 @@ -131,8 +131,8 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ## 相关资料 -- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md)负责稳定、可供机器路由的 code 与 cause chaining。 -- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)使提供方/模型和完整请求输入在分发前持久化。 -- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。 -- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)负责当前已关闭步骤的请求恢复扩展点与有界溢出重试。 -- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。 +- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md)负责稳定、可供机器路由的 code 与 cause chaining。 +- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md)使提供方/模型和完整请求输入在分发前持久化。 +- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.zh.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。 +- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md)负责当前已关闭步骤的请求恢复扩展点与有界溢出重试。 +- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 687743c0a4..1b5420b35a 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md 2026-06-21-mandatory-app-attribution-headers.md: 479d3a46dc41c5cc9ae9b77b81dbef3d6524370b -2026-06-21-mandatory-app-attribution-headers.zh.md: 75162604623099d50ca87aafd5d45567e2121789 +2026-06-21-mandatory-app-attribution-headers.zh.md: 1b11cb6ef1e96609c6777134a85de298ca979c58 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 7516260462..1b11cb6ef1 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 Agent Note 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 Agent Note](2026-06-13-twin-llm-adapters.md) 的存在正是为了确保两种实现中的提供方约定真实可靠。 +LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 Agent Note 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 Agent Note](2026-06-13-twin-llm-adapters.zh.md) 的存在正是为了确保两种实现中的提供方约定真实可靠。 直接触发因素来自 OpenRouter 的[应用归属](https://openrouter.ai/docs/app-attribution)文档。OpenRouter 根据 `HTTP-Referer` 加上用于展示和分类的头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。 @@ -24,7 +24,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 ## 决策 -在 LLM 适配器边界,提供方无关的应用归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。这条规则约束应用归属,不约束提供方特有的请求身份;[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.md)另行负责其用户与会话头部。 +在 LLM 适配器边界,提供方无关的应用归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。这条规则约束应用归属,不约束提供方特有的请求身份;[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.zh.md)另行负责其用户与会话头部。 OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本决策定义的共享 `User-Agent` 归属。 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index bc92549f44..1a7908de99 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md 2026-06-24-web-capability-seam.md: 7e7b09f19864bd2ad8ad9d69579c1d5c79600cde -2026-06-24-web-capability-seam.zh.md: d6051eec498edb640773ba367038581cebd1f654 +2026-06-24-web-capability-seam.zh.md: 88bd10705837496e7f162c992717131618b57a46 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index d6051eec49..88bd107058 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -16,7 +16,7 @@ harness 需要面向模型的 web 工具,但不能将模型约定绑定到某 ## 决策 -Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md): +Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md): 1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。 2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-http`。 @@ -34,7 +34,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 -该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 +该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 ## 包拓扑 @@ -280,7 +280,7 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 ## 测试 -每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 +每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 ## 曾考虑的替代方案 @@ -329,10 +329,10 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 - `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP(防御 DNS rebinding/TOCTOU)、跨重定向的每跳重新验证,以及 IPv6 边缘处理(私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断(OpenCode 做前缀检查后直接 fetch;Claude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的提示词),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前,`web_fetch` 只能在无法触达敏感内部目标的部署中启用。 - `pdf` `WebFetchBody` 类别:`http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。 - 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。 -- 权限策略集成:权限系统现已存在([沙箱与审批](../feature/2026-07-06-sandbox.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.md)),但只捆绑了沙箱模式与审批策略;web 权限策略仍未集成。 +- 权限策略集成:权限系统现已存在([沙箱与审批](../feature/2026-07-06-sandbox.zh.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.zh.md)),但只捆绑了沙箱模式与审批策略;web 权限策略仍未集成。 - `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。 ## 开放问题 - 产品应用包是否应在启动时探测 web 配置(当 web 被显式配置时将 `WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 和 `WEB_PROVIDER_AMBIGUOUS` 视为致命错误),还是将配置错误留到首次执行时浮出? -- 在已交付的权限系统([沙箱与审批](../feature/2026-07-06-sandbox.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.md))中,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有? +- 在已交付的权限系统([沙箱与审批](../feature/2026-07-06-sandbox.zh.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.zh.md))中,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有? diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml index c49cd144b9..421739cb57 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md 2026-06-26-file-context-as-event-gate.md: a8316741c3ce8548db5b5def431425589994995c -2026-06-26-file-context-as-event-gate.zh.md: 78b1b759edc0340fd7be103fc9bfec0b125e0800 +2026-06-26-file-context-as-event-gate.zh.md: 618985f33073cf9fd69cf2c7fda7112d9752468f diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md index 78b1b759ed..618985f330 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 +[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 这把三件本应可分离的事情耦合在了一起: @@ -33,7 +33,7 @@ provider dsh-fs-local local implementation of ctx.fs 该模型是叠加式的:裸 `ctx.fs` 执行原子化、无约束的文本 I/O,而 `dsh-fs-observation-policy` 叠加观测状态、先读后编辑和版本守卫。因此移除策略层后工具仍可用,只是不受约束。正式发布的 agent(智能体)配置会加载策略;裸模式的存在是为了让策略在服务边界保持可选,而非作为正常部署姿态。 -[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.md)把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门控归属与无 I/O 策略边界保持不变。 +[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.zh.md)把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门控归属与无 I/O 策略边界保持不变。 `dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` 和 `tools`/`systemPrompt`。 @@ -152,7 +152,7 @@ interface Events { ## 取代关系 -本 Agent Note 修正——而非推翻——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方约定和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。拆分文件系统 seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 +本 Agent Note 修正——而非推翻——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md)。四层拆分、提供方约定和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。拆分文件系统 seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 ## 验证 @@ -160,7 +160,7 @@ interface Events { ## 曾考虑的替代方案 -- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 +- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 - **策略侧版本检查**(`dsh-fs-observation-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。 - **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml index 7a5d35f882..04eec8e0e1 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md 2026-06-30-bash-stdin-env-trusted-plugin-api.md: 41be63fff598587ee9b873cf7edf51da788bc02a -2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: 4f0338cbb6a265ff629a81e9c9a12c4456941ba0 +2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: 60721539d9d37857e145e289bb262482588139bf diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md index 4f0338cbb6..60721539d9 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md @@ -8,7 +8,7 @@ Status: implemented 钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.shell` 能力 seam 后面有一个完善的命令执行器([dsh-shell](../../../../packages/shell/shell) → [dsh-bash-local](../../../../packages/shell/bash-local)),具备进程组终止、输出截断/spill 处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程底层机制——但该 seam 此前无法写入 stdin 或设置额外 env。本次变更添加这两个输入。 -`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 Service Definition 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.md)。 +`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 Service Definition 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.zh.md)。 ## 决策 @@ -16,7 +16,7 @@ Status: implemented 三个有意为之的选择: -1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置请求字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 +1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置请求字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.zh.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策负责管理 `DSH_*`:这类环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和 spill 行为。面向模型的行为不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/subsystems/shell.md)。 +钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和 spill 行为。面向模型的行为不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/subsystems/shell.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index 171f33fe17..2b51b21c05 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md 2026-06-30-event-domain-semantics.md: 70da718b5471ce309a090c8aade3e7290cc949dc -2026-06-30-event-domain-semantics.zh.md: 6795e1f6f583d6fe7d34efc5e1619da38ab1c89f +2026-06-30-event-domain-semantics.zh.md: f5d61da445f90514bd9b536bbed48734f03b8008 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md index 6795e1f6f5..f5d61da445 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 Agent Note](2026-06-11-microkernel-event-taxonomy.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊: +harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 Agent Note](2026-06-11-microkernel-event-taxonomy.zh.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊: - `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。 - `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。 @@ -26,14 +26,14 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) **边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 -**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP(Agent Client Protocol)桥接将其进行中的提示词与精确对应的 `session/event` `turn/start`/`turn/end` 事件对关联,其他 transcript 消费方同样从持久流派生边界。见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 +**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP(Agent Client Protocol)桥接将其进行中的提示词与精确对应的 `session/event` `turn/start`/`turn/end` 事件对关联,其他 transcript 消费方同样从持久流派生边界。见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 ## 后果 - 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;事件接纳失败或内部校验失败仍会在边界进入日志之前向外抛出。 - 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` 会话事件——它们所锁定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的轮次边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 - 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。 -- 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的中途 steering `user/message`。 +- 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.zh.md) 单独移除——它镜像的是持久的中途 steering `user/message`。 - 生成的 Cordis 事件表面(`docs/subsystems/` 各页)不再列出镜像事件。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index fa8401fdc8..4888175ce2 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md 2026-07-02-tool-render-intent-union.md: 52626d3d9200146df95aee7836d37bbaf7e6a9ec -2026-07-02-tool-render-intent-union.zh.md: 43fda366694aecda07b276d34c882e75d6b4aa95 +2026-07-02-tool-render-intent-union.zh.md: 544a8b14e66a93c717e156152817b473ba70c8ab diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index 43fda36669..544a8b14e6 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-02-tool-render-intent-union.md) | 中文 -> render-intent 联合类型对 UI 传输层仍然有效;其 ACP(Agent Client Protocol)映射已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。 +> render-intent 联合类型对 UI 传输层仍然有效;其 ACP(Agent Client Protocol)映射已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)取代。 ## 问题 @@ -78,5 +78,5 @@ terminal 意图只用于展示。harness 仍通过自身的 bash 服务执行命 ## 相关 - 取代早先被否决的折叠工具自有呈现提案(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 Agent Note 即为那个联合类型。 -- 被[结果时已应用 hunk 差异](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md)(已归档)扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff)——值/呈现拆分与持久化的 `presentationMeta` 通道现由[规范工具输出约定](2026-07-20-canonical-tool-output-contract.md)拥有。 +- 被[结果时已应用 hunk 差异](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md)(已归档)扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff)——值/呈现拆分与持久化的 `presentationMeta` 通道现由[规范工具输出约定](2026-07-20-canonical-tool-output-contract.zh.md)拥有。 - 将 `ToolTerminal` 折入当前 UI 传输层使用的带标签 `terminal` 视图。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 800449486f..13871d1cff 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md 2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 9a53619d9510e3f4fa561f8420b2da3bedbbf4bb -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 6174f277f0a4e8c46ff8d732411f28b4ea97728c +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: dc1164198df0f92b843c75b618f140d8aef86e4f diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 6174f277f0..dc1164198d 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -40,7 +40,7 @@ Status: implemented ### Subagent 对话历史描述符 -`SubagentProvider.inheritsParentContext` 描述的是对话历史初始化,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具描述和提示词参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。提供方生命周期事件使该措辞与响应式提供方注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)。 +`SubagentProvider.inheritsParentContext` 描述的是对话历史初始化,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具描述和提示词参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。提供方生命周期事件使该措辞与响应式提供方注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.zh.md)。 ## 曾考虑的替代方案 @@ -49,7 +49,7 @@ Status: implemented - **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本决策要治愈的病症。 - **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 - **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈在 leaf YAML 中手写指导的漂移。**根据提供方名称选择措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。 -- **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。 +- **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.zh.md)中被否决。 ## 不在范围内 diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml index 336d9669ed..a66c6f6065 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md 2026-07-05-subagent-provider-lifecycle-events.md: 503c0d638785e3a4944c8903b8d7d469c68b1881 -2026-07-05-subagent-provider-lifecycle-events.zh.md: e7d1d8c398a6d77efb2dbccc365e0040f38f2d33 +2026-07-05-subagent-provider-lifecycle-events.zh.md: d9d15b4ff688506b84b81c8649e1de4ce50e086d diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md index e7d1d8c398..d9d15b4ff6 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md @@ -6,9 +6,9 @@ Status: implemented ## 问题 -[提示词变量 Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn 和 ACP(Agent Client Protocol)为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 +[提示词变量 Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn 和 ACP(Agent Client Protocol)为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 -如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/defensive-patterns.md))。 +如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/defensive-patterns.zh.md))。 ## 决策 @@ -31,6 +31,6 @@ Status: implemented ## 后果 - 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。 -- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/subsystems/subagent.md#cordis-surface)与[生产者/消费方映射](../../../../docs/event-producer-consumer.md)。 +- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/subsystems/subagent.zh.md#cordis-surface)与[生产者/消费方映射](../../../../docs/event-producer-consumer.zh.md)。 - **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表发出的 `tools/change` 事件会使提示词组装保持最新状态。 - **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例分别指定了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在其提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml index cf8eaf225c..cc278292d9 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md 2026-07-05-windows-jsonl-durable-publish.md: 546cde6086f3c84e22b4d4de144a48bb3425cd4e -2026-07-05-windows-jsonl-durable-publish.zh.md: 205460bcd374bd351cfdf541eb4041c09461229d +2026-07-05-windows-jsonl-durable-publish.zh.md: b99032e9d7e72e2028f39c5af91659278fd81343 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md index 205460bcd3..b99032e9d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md @@ -28,7 +28,7 @@ Windows 通过持久的暂存发布来创建缺失目录:创建一个以固定 ## 影响 -该后端在各平台上维持同一项外部约定:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.md)会先作用于不透明字节,然后才由任一平台执行发布。 +该后端在各平台上维持同一项外部约定:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.zh.md)会先作用于不透明字节,然后才由任一平台执行发布。 Windows 测试会在原生 Windows 上执行真实的 Win32 发布路径。断电行为属于 API 约定属性,单元测试无法证明;可测试的不变量包括:Windows 物化不会调用目录 fsync、最终路径冲突会失败、达到最大长度的目标路径组件仍可物化、临时日志在发布前已经执行 fsync,并且生成的日志可以正常加载。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index e7e49aa115..580e74f217 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md 2026-07-06-timeout-deadline-library.md: 38f048d16ecba0e5278ae34b0c88b7889dcfa47a -2026-07-06-timeout-deadline-library.zh.md: e8b0ab62b19d931c33027d5e6c9919804e80aa99 +2026-07-06-timeout-deadline-library.zh.md: c8d189c2a7588ee57b0f7fe02137b78c9ad7ff9d diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index e8b0ab62b1..c8d189c2a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -103,7 +103,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 - 模型流现在共享一个可重启的定时器约定,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。能够观察到带外传输活动的适配器可以对尚未结算的 demand 调用 `pulse()`;被屏蔽的活动对 watchdog 仍不可见。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。 -以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;基于 ripgrep 的文件系统发现工具([打包的 ripgrep 搜索](2026-08-01-packaged-ripgrep-search.md))通过 `dsh-tool-call-timeout-policy` 和 `exec.signal` 消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 +以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;基于 ripgrep 的文件系统发现工具([打包的 ripgrep 搜索](2026-08-01-packaged-ripgrep-search.zh.md))通过 `dsh-tool-call-timeout-policy` 和 `exec.signal` 消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml index 5e943fed2b..398eaab528 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md 2026-07-06-tool-result-retention-library.md: 8d938db8f5fa78398a39e97cc877308200f7d60f -2026-07-06-tool-result-retention-library.zh.md: 0b2fe841beed21511c4088732713d3fd9fd9048f +2026-07-06-tool-result-retention-library.zh.md: 747b545ee8c900c13d80c7aef6caecc8ae8dc0ad diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md index 0b2fe841be..747b545ee8 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md @@ -103,7 +103,7 @@ type TextRetentionStrategy = `grep` 在分组前使用 `ItemRetainer`,并将其配置为 `{ kind: 'head', maxItems: grepMaxMatches }`。执行器解析 ripgrep 输出、映射路径、应用逐行预览截断,并输入扁平匹配项。调用 `finish()` 后,工具按文件对保留的匹配项分组;如果行内结果达到上限,还可以通过 spill seam 保存完整匹配列表。分组不属于 retainer,因为上限针对匹配总数,而不是文件数;逐匹配项的预览截断和 `incomplete` 也与结果级保留相互独立。 -`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责 spill 文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.md)相互独立。 +`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责 spill 文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.zh.md)相互独立。 `web_fetch` 可以使用 `TextRetainer`,配置为 `head` 或 `headTail`;如果提供方必须在内部读取和解码,也可以保留由提供方负责的正文上限。无论采用哪种方式,fetch 结果中的 `truncated` 仍是提供方/工具事实,该库只提供保留文本与省略元数据。 @@ -138,7 +138,7 @@ const formatGrepNotice = (notice: RetentionNotice): string => **已交付内容。** `@deepseek-ai/dsh-output-retention` 导出 `ItemRetainer`、`TextRetainer`、结果类型(`RetainedItems`、`RetainedText`)、策略类型(`ItemRetentionStrategy`、`TextRetentionStrategy`)、`Omitted`、`PushDecision`、`RetentionNotice`,以及中性的提示辅助函数 `describeOmitted`/`formatRetentionNotice`,且不依赖 Cordis 或任何工具包。单元测试覆盖具有精确省略计数的条目头部保留、文本头部保留、文本尾部保留、首尾字节保留、零预算、UTF-8 边界处理(2、3、4 字节码位,以及每个裁切位置上的无效起始字节)和未知省略量的措辞。 -**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/output-retention/README.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口约定(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。 +**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/output-retention/README.zh.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口约定(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。 **该库维持的边界。** `truncated` 表示 retainer 因预算省略了原本可用的内容,绝不表示上游不完整。工具专用状态,包括 `incomplete`、权限失败、提供方局部失败、跳过二进制文件、bash spill 路径恢复和无效 UTF-8,均留在工具领域字段中、位于 retainer 之外。未来改动迁移某项工具时,该包的 README 与测试必须证明,除了有意改变的提示措辞外,模型可见的结果文本没有变化。 @@ -152,6 +152,6 @@ const formatGrepNotice = (notice: RetentionNotice): string => **把 `read` 窗口交给 `ItemRetainer`。** v1 不予采纳:`read` 是当前唯一的窗口消费方,其语义属于文件分页,而不是通用保留。一个 `Omitted` 计数无法表示行窗口两侧,而且 `read` 还携带 `totalLines`、offset 范围错误、逐行预览截断和针对所选输出的字节上限。让 `read-render` 由工具所有,可以避免共享库围绕一项特例膨胀。 -**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer(原生渲染器)使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.md)可以保留完整的已采集结果。 +**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer(原生渲染器)使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.zh.md)可以保留完整的已采集结果。 **在每个面向模型的工具 schema 中公开上限。** 不作为默认方案:Claude Code 的 grep 公开 `head_limit`/`offset`,但本 harness 会把常规预算保留为部署配置,除非模型确实需要控制分页。未来可以为具体工具增加类似 read 的续传字段;它不属于共享保留原语。 diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml index aad3aa26f0..5167819d72 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md 2026-07-07-tool-call-timeout-policy.md: ce414e541f8e374dd48e46d68cb00121e0004247 -2026-07-07-tool-call-timeout-policy.zh.md: 6fe3c979a3c4e7b7a6ed803a47af45ad32d52cce +2026-07-07-tool-call-timeout-policy.zh.md: 6b1975981b0311cf0ea02ee9667720c414bb7b43 diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md index 6fe3c979a3..6b1975981b 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[超时/截止时间 Agent Note](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。 +[超时/截止时间 Agent Note](2026-07-06-timeout-deadline-library.zh.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。 与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.shell` 执行命令钩子,而非通过 `ctx.tools.execute()`;`bash` 模型工具通过同一个后端复用前台执行、后台启动、后台轮询和钩子复用。一步到位地将所有超时移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。 @@ -16,7 +16,7 @@ Status: implemented - `@deepseek-ai/dsh-timeout` 仍是拥有 `deadline()` 和 `timeoutOf()` 的共享库。 - `@deepseek-ai/dsh-tools` 在 `tools/pre-execute` 和 `tools/post-execute` 之间有一个环绕分发的 waterfall(瀑布式事件)`tools/execute`。 -- [仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.md)使用 `@deepseek-ai/dsh-tool-call-timeout-policy`,准确说明该策略所限制的操作。插件从 runtime 读取每个工具声明的 `timeoutMs`,并通过派生新的 `exec.signal` 来包装有此声明的调用。 +- [仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)使用 `@deepseek-ai/dsh-tool-call-timeout-policy`,准确说明该策略所限制的操作。插件从 runtime 读取每个工具声明的 `timeoutMs`,并通过派生新的 `exec.signal` 来包装有此声明的调用。 执行流水线如下: @@ -52,7 +52,7 @@ catch 是基础 `next`(而非 waterfall 之外的东西)这一点至关重 searchTimeoutMs: 30000 ``` -超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号并将其赋给 `exec.signal`;注册表依据[工具取消约定](2026-07-19-cooperative-tool-cancellation.md),在执行工具体之前将该截止信号与调用方的原始信号融合。执行器随后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 +超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号并将其赋给 `exec.signal`;注册表依据[工具取消约定](2026-07-19-cooperative-tool-cancellation.zh.md),在执行工具体之前将该截止信号与调用方的原始信号融合。执行器随后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的任何参数,并以共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此修改共享对象是包装器向注册表提供截止信号的方式。注册表会在进入工具体前再次融合已捕获的调用方信号;插件则在 `finally` 中将 `exec.signal` 恢复为调用方的原始值,使 `tools/post-execute` 永远不会看到本插件的截止信号。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index efed9ca5ea..20d0e4f85c 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md 2026-07-08-agent-scope-contexts.md: eb3f6f247bac1a1d81aa2644132c7b9cc04d602c -2026-07-08-agent-scope-contexts.zh.md: fe82ac18d07de97330e86461e6bdf86edc37d2ae +2026-07-08-agent-scope-contexts.zh.md: a0f4ffb0ef80dd2fc1ee61c9ab3f4730c28c687e diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index fe82ac18d0..a0f4ffb0ef 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -16,7 +16,7 @@ Status: implemented 每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的上下文进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。 -Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.md)对该框架有更详细的说明。 +Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.zh.md)对该框架有更详细的说明。 对大多数贡献者而言,完整约定是四条规则: @@ -45,7 +45,7 @@ flowchart LR 缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。 -配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 +配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 ### 注册来源决定可见性与清理 @@ -104,7 +104,7 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插 在 Cordis 层面,`Scoped` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。 -以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。详尽的事件参考是各[子系统页面](../../../../docs/subsystems/core.md)上生成的 `cordis-surface` 区块的集合——每个事件作用域在其所属页面上(`agent/*` 与 `agent-loop/*` 在 core.md 本页)。 +以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。详尽的事件参考是各[子系统页面](../../../../docs/subsystems/core.zh.md)上生成的 `cordis-surface` 区块的集合——每个事件作用域在其所属页面上(`agent/*` 与 `agent-loop/*` 在 core.md 本页)。 ### 创建最后发布,dispose 最后撤销 @@ -138,6 +138,8 @@ flowchart TB detach --> revoke["Dispose the agent scope"] ``` + + ## 安全与权限是非目标 agent 作用域组合的是受信的同进程注册。它不沙箱化插件、不定义父到子的权限格、不在创建时冻结授权、也不保证子级不能做超出父级的事。 @@ -146,6 +148,8 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件、 需要非升权保证的部署需要独立的权限表示、传播规则和执行检查。父级子集授权、创建时授权快照、显式未来授权 API,以及通用的能力/输出/终止标签均不在本决策范围内。 + + ## 曾考虑的替代方案 被否决的设计要么将可见性与清理分离,要么只覆盖一类注册,要么重复共享基础设施,要么将生命周期所有权与继承混为一谈。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml index 5f9f8ba421..15abdc91f0 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md 2026-07-08-tool-output-spill-files.md: 4d1d4b7b665f34b362df2f8c8aeb06d96bf2668f -2026-07-08-tool-output-spill-files.zh.md: 1a3387830861c6d05318024870325523b8eeee36 +2026-07-08-tool-output-spill-files.zh.md: 3e7ce4f57a0078c6a4b919436946be8e172fa7cb diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md index 1a33878308..3e7ce4f57a 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -8,9 +8,9 @@ Status: implemented 工具输出需要有界的模型可见预览,但部分超大结果仍可能在之后有用。抓取的页面正文或冗长的工具响应不应完整占用下一次模型请求,但模型应能使用现有文件读取工具,在之后查看经过格式化的完整结果。 -这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时 spill 文件;普通文本工具结果则仍以内联形式返回,除非工具自行实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。 +这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时 spill 文件;普通文本工具结果则仍以内联形式返回,除非工具自行实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.zh.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。 -其形态与超时策略设计一致:工具作者声明规范值与 Native renderer(原生渲染器),由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前 spill;由工具负责的展示 spill 可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出约定](2026-07-20-canonical-tool-output-contract.md)规定了这项区分。 +其形态与超时策略设计一致:工具作者声明规范值与 Native renderer(原生渲染器),由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前 spill;由工具负责的展示 spill 可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出约定](2026-07-20-canonical-tool-output-contract.zh.md)规定了这项区分。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index 0321d494a0..ec2f5318d6 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md 2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 89c8917484c11057ff2e6b55e2311e83f724f7ed -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 96f7aa1b0ea4e44ca6318caca2c785d898b1d00d +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 48f954f2c4720894548ecab387965d3a3d1799b0 diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 96f7aa1b0e..48f954f2c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -22,9 +22,9 @@ Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步 ### 请求恢复只覆盖最终模型边界 -`agent/request-error` 表示来自最终适配器边界的终止失败。适配器选择、分发、iterator 构造与迭代抛出会在 agent loop(智能体循环)消费前成为终止 `error` 或 `aborted` finish;适配器直接发出的终止 finish 进入同一路径。提示词装配、请求 middleware、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.md)规定这一规范化边界。 +`agent/request-error` 表示来自最终适配器边界的终止失败。适配器选择、分发、iterator 构造与迭代抛出会在 agent loop(智能体循环)消费前成为终止 `error` 或 `aborted` finish;适配器直接发出的终止 finish 进入同一路径。提示词装配、请求 middleware、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.zh.md)规定这一规范化边界。 -恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compaction-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回边界。 +恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compaction-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.zh.md)规定这一返回边界。 如果取消发生在 assistant 工具调用已经持久化之后、所有调用完成分发之前,循环会为每个尚未分发的调用记录一对合成的 `tool/call` 与 aborted `tool/result`,随后进入正常中止路径。因此,表层不会仅因取消赢得竞态而留下孤立的持久工具调用。 @@ -58,4 +58,4 @@ Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步 代价是在共享 pre-step waterfall 中执行压力工作,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复不可剪枝的剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。 -[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围约定、日志中记录的锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 +[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围约定、日志中记录的锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 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 79be5c180b..a58ce97eff 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md 2026-07-10-single-file-executable-sdk-runtime-distribution.md: fa2f86893b730aa1ba020bd568d268ec8d9d6239 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 509bec18edb9923dd4d60d4ecf30d4fbcd9cc6d5 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 4778a2b83ad12fc4155399440e387dde86e3bfc4 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 509bec18ed..4778a2b83a 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 @@ -27,8 +27,8 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件: -- [`packages/sdk/server`](../../../../packages/sdk/server/README.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 -- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 +- [`packages/sdk/server`](../../../../packages/sdk/server/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 +- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。 @@ -42,11 +42,11 @@ 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 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--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):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)则调用它构建全部目标。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 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)则调用它构建全部目标。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 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 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-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.zh.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-sdk-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` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 02aeec5ad6..28bdb324ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md 2026-07-12-agent-scope-runtime-design.md: 9d70b8048b1d2bb50290d34158d9deb329d5e15e -2026-07-12-agent-scope-runtime-design.zh.md: a505f5337559294795c1080937c6079cee235baa +2026-07-12-agent-scope-runtime-design.zh.md: 6ed883741b31d80bfe4392278dc2a0dda167f711 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index a505f53375..6ed883741b 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[agent(智能体)作用域约定](2026-07-08-agent-scope-contexts.md)对贡献者而言很简单:通过 `agent.ctx` 注册,解析出一个全局加单 agent 的视图,仅在 setup 完成后发布,并保持作用域直到工作停止。运行时必须在协作式插件框架、异步创建、可重入监听器、持久化会话提交以及 worker 或进程故障等场景下维护这份约定。 +[agent(智能体)作用域约定](2026-07-08-agent-scope-contexts.zh.md)对贡献者而言很简单:通过 `agent.ctx` 注册,解析出一个全局加单 agent 的视图,仅在 setup 完成后发布,并保持作用域直到工作停止。运行时必须在协作式插件框架、异步创建、可重入监听器、持久化会话提交以及 worker 或进程故障等场景下维护这份约定。 主要的设计风险是为每个竞态条件引入第二套机制。独立的预留、就绪哨兵、取消中继、快照层和保护注册表可能镜像同一个事实,直到没有读者能分辨哪个才是权威的。这些机制还会诱使运行时把可信的类型化调用当作敌对的序列化边界来处理。 @@ -30,7 +30,7 @@ Status: implemented 本 Agent Note 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与提示词、subagent 与工作流,最后是可执行检查。 -[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md) 仍然是贡献者约定。独立的 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。 +[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.zh.md) 仍然是贡献者约定。独立的 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。 ## Cordis 模型:上下文、fiber、effect、receiver 与 waterfall @@ -58,6 +58,8 @@ Cordis 使用 dispatch receiver(`this`)过滤监听器,而 harness 的监 Cordis waterfall 是中间件风格的 dispatch。每个监听器接收 `next()`:调用它则委托给剩余监听器和基础操作,不调用则短路或替换下游结果。Waterfall 驱动提示词组装和工具策略;普通 emit 事件同步通知,parallel 事件等待所有监听器但没有否决结果。 + + ## 作用域路由:一个不透明键选择一层 scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个组合的服务过滤器和作用域谓词,而包私有地记录不透明键,并单独暴露会等待作用域 fiber 完全停稳的 disposer。 @@ -72,7 +74,7 @@ Receiver 是一个小型载体而非领域对象的透明代理。需要 agent ### 注册表读取叠加一个精确 layer -作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个上下文派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.md))。 +作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个上下文派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.zh.md))。 每个服务保留其领域规则。命名 command 和提示词视图使用共享的、保持插入顺序的 shadow 合并;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 Code Mode transport 则单独插入。提示词变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。 @@ -208,7 +210,7 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、Code Mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。 -[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 +[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 `ToolRestriction` 接受 readonly 的 allow/deny 名称并将其编译为内部集合。多个限制取交集。公开的 `visible()` 和 `knownNames()` 方法是不必要的,因为只有注册表需要中间视图。 @@ -230,6 +232,8 @@ SystemPrompt 首先将全局加 agent 的段、变量和工具提供方解析为 Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子级的精确作用域中,而 Code Mode 从同一个已解析的工具视图派生其传输和 SDK。第二套命名保护系统需要另一套所有权和碰撞规则来覆盖任意 schema 提供方(包括有意贡献重复名称的提供方),却不创建新的信任边界。 + + ### 结构化输出仅提交权威结果 结构化输出将子作用域组合与两阶段执行提交相结合。子级在发布前注册其 `structured_output` 工具和指令;可信的 assembly 监听器可以变换这些普通贡献,并有责任在期望子级完成时保持协议。工具体验证候选值并按当前 `ToolExecution` 暂存,但成功捕获仅由不可变的 `tools/result` 观察决定。 @@ -242,6 +246,8 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子 纯 Code Mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。 + + ### 三个执行边界有意设为单向 提示词组装有意是协作式的,但三个执行事实在其可扩展阶段之后需要单向结算: @@ -266,7 +272,7 @@ subagent 启动有一次所有权转移。提供方拥有未发布资源,直 ### 服务约定有一个取消通道 -`SubagentProvider.start()` 和 `SubagentRuntime.start()` 返回 `Promise`。Promise 会在后端跨过发布边界后兑现,因此调用方和 `subagent/start` 观察者从不需要第二个 `run.started` promise。提供方工作如果在发布前失败,`start()` 就会被拒绝;发布后的提示词、轮次、取消与基础设施结果会通过 `SubagentRun.result` 结算,且不会隐藏 child id,这也是[持久化目录决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)所要求的约定。 +`SubagentProvider.start()` 和 `SubagentRuntime.start()` 返回 `Promise`。Promise 会在后端跨过发布边界后兑现,因此调用方和 `subagent/start` 观察者从不需要第二个 `run.started` promise。提供方工作如果在发布前失败,`start()` 就会被拒绝;发布后的提示词、轮次、取消与基础设施结果会通过 `SubagentRun.result` 结算,且不会隐藏 child id,这也是[持久化目录决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)所要求的约定。 `SubagentStartRequest.signal` 是必需的。中止它会在启动期间,以及已发布 run 的剩余就绪或轮次工作中请求取消。`SubagentRun.dispose()` 也请求取消并等待完全停稳。没有单独的公开 `run.cancel()` 通道。 @@ -292,6 +298,8 @@ Start 仅在 `initialize` 和 `newSession` 成功后才 resolve。Abort、spawn Worker 和子进程桥接比同进程注册表需要更多状态,因为消息、进程死亡和清理可以独立结算。它们的状态围绕这些真实事实组织,而非重复的取消协议。 + + ### 工作流子级是待定 start 或已发布记录 工作流宿主保持待定的提供方 start promise 和已发布的子级记录。子级仅在异步 `SubagentRuntime.start()` 兑现时才从待定变为已发布;被拒绝的 start 清理其部分提供方工作且不产生子级生命周期对。 @@ -308,7 +316,7 @@ Worker 边界仍然序列化请求和结果。宿主保留首个终端结果仲 ### ACP 提示词结算不依赖更新投递 -[仅面向自动化的 ACP 桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)直接将一个进行中的提示词与其观察到的用户消息轮次关联。它不从日志水位线扫描,也不使用会话状态作为第二个调和预言机。 +[仅面向自动化的 ACP 桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)直接将一个进行中的提示词与其观察到的用户消息轮次关联。它不从日志水位线扫描,也不使用会话状态作为第二个调和预言机。 即使已提交消息的更新无法送达客户端,会话事件监听器也会从匹配的 `turn/end` 结算关联。因此更新投递不能让会话永久处于进行中状态。ACP 创建由服务器分配 id 的全新会话,并拥有由此产生的每个 agent 句柄,直到连接拆除。 @@ -330,13 +338,13 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 ### 生成的产物使公开约定保持对齐 -事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md) 拥有 Program 构造、语义事件发现和解析器生成规则。 +事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.zh.md) 拥有 Program 构造、语义事件发现和解析器生成规则。 行为测试固定了作用域路由和 dispose、最终写入注册表时的碰撞清理、发布回滚、有序完全停稳、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式提示词组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 ## 曾考虑的替代方案 -[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md#alternatives-considered) 拥有公开扁平作用域约定的替代方案。此处的替代方案关注实现形态。 +[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.zh.md#alternatives-considered) 拥有公开扁平作用域约定的替代方案。此处的替代方案关注实现形态。 ### 使用透明代理作为作用域载体 @@ -389,4 +397,4 @@ Worker 消息、进程死亡和持久化输入确实跨越所有权和序列化 该设计信任同进程中的类型化插件。它不防御任意强制转换、有状态 getter、违反 readonly 约定的修改,或插件有意在支持的组合 API 之外使用环境服务访问。 -[安全与权限非目标](2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)仍然是根本性的。这些机制证明注册组合、发布和生命期所有权;它们不证明隔离或父到子的非升权。 +[安全与权限非目标](2026-07-08-agent-scope-contexts.zh.md#security-and-authority-are-non-goals)仍然是根本性的。这些机制证明注册组合、发布和生命期所有权;它们不证明隔离或父到子的非升权。 diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml index 86871f2bcd..205c3c4e39 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md 2026-07-12-scoped-layers-store.md: 221711748007d5f909aec3fdfcf3919884d11cdb -2026-07-12-scoped-layers-store.zh.md: 7f3ad11e23f02eeafe955efe08525bda8a9e9996 +2026-07-12-scoped-layers-store.zh.md: 97ad637b91a2b9b2cad9847d8b4cdbe23ff14a48 diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md index 7f3ad11e23..97ad637b91 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.md)、[运行时设计](2026-07-12-agent-scope-runtime-design.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandRuntime.register`(位于 `dsh-commands`)。 +agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.zh.md)、[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandRuntime.register`(位于 `dsh-commands`)。 如果没有共享原语,每个门面都要围绕自己的领域状态重复相同的生命周期编排:从调用方上下文导出可见性,按需创建专属容器,把属主绑定到同一个 Cordis fiber,先装入 undo 再通知观察者,原样返回 Cordis 的 disposer,并回收空的专属状态。各自分离的映射与集合类型也会让服务缺少一个表示某个 scope 完整贡献的对象。 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index 3f4683f480..34523f2723 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md 2026-07-14-provider-routed-llm-adapters.md: 78c8d6788006c503b532ff2bbddd30342415f0a4 -2026-07-14-provider-routed-llm-adapters.zh.md: 5e73cab5f1f2b1296c9a486d1c833e95bb5674a0 +2026-07-14-provider-routed-llm-adapters.zh.md: af8bc4fe27a50d47d7b49b51eada67afe889fc13 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index 5e73cab5f1..af8bc4fe27 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -20,7 +20,7 @@ Status: implemented `GenerateOptions` 与 `LlmCallConfig` 在 `model: string` 之外携带 `provider: string`,`AgentOptions` 则携带对应的可选创建字段。只有两个值都非空时,agent loop(智能体循环)请求才有效;两个值也都会写入请求头日志。`agent/request` 可以在任意步骤返回替换后的字段组合,因此会话可以切换提供方与模型,无需改变 Cordis 插件生命周期。 -`LlmRuntime` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并以一个 effect 为单位整体 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。 +`LlmRuntime` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并以一个 effect 为单位整体 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.zh.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。 在一个 Cordis 上下文中,一个提供方只能有一个适配器所有者。`dsh-llm-deepseek` 注册 `deepseek`;`dsh-llm-pi-ai` 也可以注册 `deepseek`,但同时加载两个所有者属于配置错误,不采用顺序规则或回退行为。若部署选择手写的 DeepSeek 实现,需从 pi-ai 配置中排除 `deepseek`;若部署选择 pi-ai 的 DeepSeek 实现,则不挂载 `dsh-llm-deepseek`。 @@ -40,11 +40,11 @@ pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定 助手消息携带请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些字段,`deriveMessages()` 返回助手消息时也会包含它们。用户、系统、上下文与工具结果消息不携带助手路由字段。提供方/模型字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。 -成功的终止 `finish` 分片可以以 `ReplayEnvelope` 形式携带回放状态:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。`BlockAssembler` 对内容与元数据只做一次保留/丢弃决定——max-token 组装丢弃工具调用时,数据同一位置的条目一并丢弃——因此 agent loop 附加到已组装助手消息模型来源中的状态始终描述存储的块,见 [max-token 回放状态对齐决定](../bug-fix/2026-08-15-max-token-replay-state-alignment.md)。agent loop 不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 +成功的终止 `finish` 分片可以以 `ReplayEnvelope` 形式携带回放状态:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。`BlockAssembler` 对内容与元数据只做一次保留/丢弃决定——max-token 组装丢弃工具调用时,数据同一位置的条目一并丢弃——因此 agent loop 附加到已组装助手消息模型来源中的状态始终描述存储的块,见 [max-token 回放状态对齐决定](../bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md)。agent loop 不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 pi-ai 回放状态用其成功 `AssistantMessage` 的带版本最小投影填充该结构:一个响应半区(源 API/提供方/模型、响应 ID/模型、停止原因),以及逐块的文本签名、thinking 签名和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmRuntime` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。持久化内容保持权威:适配器收到无法使用的回放状态——未知 kind 或版本、格式错误的元数据、或与内容不再匹配的块结构——会把该消息降级为提供方无关转换并带出诊断;其他适配器只能收到提供方无关的内容以及提供方/模型字段。 -该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 模型来源中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。 +该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.zh.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 模型来源中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。 ### 在所有请求生产方中传播目标 diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml index 458999f905..570b68263c 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md 2026-07-15-agent-initiator-scope.md: 63540c0ec6b29a10613e01f1ed9ced24e8f2d277 -2026-07-15-agent-initiator-scope.zh.md: a0e3638081d875adc2412191829ab84c8dcd697c +2026-07-15-agent-initiator-scope.zh.md: 3ea893aa5f6992bf09965436c1db3144d2fae5ac diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md index a0e3638081..3ea893aa5f 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md @@ -12,7 +12,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 ## 决策 -必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../../docs/subsystems/core.md#initiating-agent)标明了所携带的类型。 +必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../../docs/subsystems/core.zh.md#initiating-agent)标明了所携带的类型。 `currentInitiator()` 用于可选读取,`requireInitiator()` 抛出 `no initiating agent is active`,`withInitiator(agent, operation)` 保留操作返回的同步值或 Promise 本身。`withoutInitiator(operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。 @@ -28,7 +28,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。 -本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。 +本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。 ## 验证 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 69af08605c..c9fe4938a3 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md 2026-07-15-llm-model-catalog-and-acp-selection.md: 3dddbe7e9fae74e4ce1ec1c8e93a40c3352b5a54 -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 145fd0bc379ff8132d09ec628b6381a18f755cfa +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: b93b333c6f25a779b1d307b6141bea9f5eb1e407 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 145fd0bc37..b93b333c6f 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 @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-15-llm-model-catalog-and-acp-selection.md) | 中文 -> 目录决策仍然有效。ACP(Agent Client Protocol)会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。 +> 目录决策仍然有效。ACP(Agent Client Protocol)会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)取代。 ## 问题 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index aee9c76d55..277e0b097d 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md 2026-07-15-lsp-capability-seam.md: 90f9daf4b890bd53621916e492d4d82baaa3e8cc -2026-07-15-lsp-capability-seam.zh.md: b85b478b25a375b04e1301d893e53198554641e3 +2026-07-15-lsp-capability-seam.zh.md: bdb5e812e94a4aec4402fe83ca9c818bfa00f9f9 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index b85b478b25..bdb5e812e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -79,7 +79,7 @@ interface LspService { 映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。seam 中的位置和范围从零开始按 UTF-16 计数。`findReferences` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方的规范工作区 URI,使消费方在执行世界的命名空间内相对化文件 URI。seam 不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 -`dsh-lsp-stdio` 负责服务器配置、JSON-RPC、进程与临时文档状态和协议转换。它通过 `ctx.fs` 读取,通过 `ctx.subprocess` 启动,只依赖二者的 Service Definition 包而非具体提供方;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)负责定义这种配对。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 +`dsh-lsp-stdio` 负责服务器配置、JSON-RPC、进程与临时文档状态和协议转换。它通过 `ctx.fs` 读取,通过 `ctx.subprocess` 启动,只依赖二者的 Service Definition 包而非具体提供方;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.zh.md)负责定义这种配对。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 ## 面向模型的约定 diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index 47e7156ab8..6ad1773f6f 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md 2026-07-15-replay-token-meter-service.md: 10261189fca621b305b3ec6347b54471d81c8ba3 -2026-07-15-replay-token-meter-service.zh.md: 4e460b3c32630f205f447a16205bd4c9b1d89faf +2026-07-15-replay-token-meter-service.zh.md: 370d3cf932d37702c5298bc79e9dc8f5a37835b9 diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 4e460b3c32..370d3cf932 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -16,7 +16,7 @@ Status: implemented `@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeter` 本身公开 `measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 -服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。对精确提供方/模型容量的查询由适配器单独负责,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md)。 +服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。对精确提供方/模型容量的查询由适配器单独负责,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.zh.md)。 ### 逐会话回放折叠 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 342f2073ea..fcca895513 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md 2026-07-16-explicit-turn-cancellation.md: 86faad9929d3eb5b00e66bb1c46a2e35b135d954 -2026-07-16-explicit-turn-cancellation.zh.md: 5397b368628c196efc2b35c00855267246ac6e85 +2026-07-16-explicit-turn-cancellation.zh.md: acf56e0629668a227324045ffd0521619dc45f33 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index 5397b36862..acf56e0629 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -8,7 +8,7 @@ Status: implemented 取消是一种生命周期短于 Agent(智能体)驱动器的控制能力。自由文本字符串无法完整区分所有调用方,步骤级控制器也无法中断提示词提交、提示词组装、继续决策或轮次终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会向持久化回放暴露不稳定的运行时细节。 -[发起 Agent 作用域决策](2026-07-15-agent-initiator-scope.md)有意让 AsyncLocalStorage 只携带同一个 Agent。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的边界,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。 +[发起 Agent 作用域决策](2026-07-15-agent-initiator-scope.zh.md)有意让 AsyncLocalStorage 只携带同一个 Agent。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的边界,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。 ## 决策 @@ -18,9 +18,9 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 -对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 +对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 +显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.zh.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index e772ff9036..29807cfb7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md 2026-07-19-cooperative-tool-cancellation.md: 781202688a5cbcd7076ee694fc7dd9489683d8e8 -2026-07-19-cooperative-tool-cancellation.zh.md: a5ce2e671e92f4758ddeb3d3556d8af574467f7f +2026-07-19-cooperative-tool-cancellation.zh.md: ec35734eef91c5c774d1be814b221e9fdb8f65fa diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index a5ce2e671e..ec35734eef 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -42,7 +42,7 @@ Status: implemented 工具主体一旦启动,注册表就会等待它完成。取消通过融合信号到达工具主体,但注册表不会与其 promise 竞速或丢弃该 promise。协作式实现会停止自身工作或继续转发取消,并在所持有的工作完全停稳后完成;不协作的同进程实现可能让注册表无限期保持等待。进程、worker、网络和提供方层仍负责各自的终止机制。 -这项决策只要求工具调用边界携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力 seam 中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md)。 +这项决策只要求工具调用边界携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力 seam 中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md)。 ## 验证 @@ -62,7 +62,7 @@ Status: implemented **禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套操作作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。 -**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it)。 +**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns.zh.md#dispose-must-reach-quiescence-not-just-request-it)。 ## 后果 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 b864b41f76..1ed087f38c 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md 2026-07-19-gui-layering-and-rpc-protocol.md: 620803668e88f5a462ab2a75e6e916a85d433ed6 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 8145b6e5af149f79b45e03c80aeea90da90b3729 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 137cbce3e5dbc0be2736472f6e26d58112422697 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 8145b6e5af..137cbce3e5 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 @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 -> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.md)。 +> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.md)组成,浏览器对象层见 [Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.zh.md)。 ## Problem @@ -23,13 +23,13 @@ Status: implemented 目录按照如下分层: - `packages/host/*`:包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含 - 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节 -- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有): +- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.zh.md) 所有): - **纯库**(`ui-slots`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;两个客户端库播种进模块表。 - **静态到达 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-web-frontend`)是 vite 应用:`dsh-client-web` 导出的壳 API 之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-web-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 + - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-web-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.zh.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -50,9 +50,9 @@ harness core packages ──────────────────┘ - `runtime → apiproxy` 单向;apiproxy 仅依赖类型定义。 - client 侧包**永不 import** host 侧包的运行时(只吃 `/api`、`/client` 两个浏览器安全子路径)。 - `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。 -- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有)。 +- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.zh.md) 所有)。 -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))。 +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.zh.md))。 协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息按「谁发起 × request/response」分类(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 @@ -167,7 +167,7 @@ export type ResponseValue = ### 帧(server→client,具名 union) -两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)。帧示例一行: +两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.md)。帧示例一行: | 帧 type | 载荷 | 何时发 | |---|---|---| @@ -214,7 +214,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| | `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) | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.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-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 2d5aaa3a25..9ee365f446 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md 2026-07-19-gui-web-client-architecture.md: 8b4f940299cbba78d403c34b1e5fc9740e44f2c2 -2026-07-19-gui-web-client-architecture.zh.md: a86f9d6f71bed0ce147ffee39bd921037d6c1c4c +2026-07-19-gui-web-client-architecture.zh.md: 705b1337dd97ac37bd01bdcc5aa22484b7971908 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 a86f9d6f71..705b1337dd 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 @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-web-client-architecture.md) | 中文 -> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/约定面/客户端基类)见 [分层与 RPC 协议笔记](2026-07-19-gui-layering-and-rpc-protocol.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 +> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/约定面/客户端基类)见 [分层与 RPC 协议笔记](2026-07-19-gui-layering-and-rpc-protocol.zh.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 ## Problem @@ -30,21 +30,21 @@ Status: implemented ## client cordis 树与装载链 -装载链——两类包(普通包 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 Modules 都内联在其持有插件的 bundle 中,物化时注入为 `` } + case 'html': + return { placement: row.placement, markup: row.html } + default: + return assertNever(row) + } +} + +/** Insert `markup` into `html` at `at`. */ +function splice(html: string, at: number, markup: string): string { + return `${html.slice(0, at)}${markup}${html.slice(at)}` +} + +/** + * Render rows into an index.html body: head rows immediately after the + * opening head tag, body rows immediately after the opening body tag, each + * group in table order. + * @param html - the raw index.html body. + * @param rows - the collected injection table. + * @returns the html with every row rendered. + */ +export function renderIndexInjections(html: string, rows: readonly IndexInjection[]): string { + let head = '' + let body = '' + for (const row of rows) { + const rendered = renderRow(row) + if (rendered.placement === 'head') head += rendered.markup + else body += rendered.markup + } + let out = html + if (head !== '') { + const open = /]*)?>/i.exec(out) + // Headless fixture pages may lack ; prepending keeps the rows ahead + // of every document script. + out = open === null ? `${head}${out}` : splice(out, open.index + open[0].length, head) + } + if (body !== '') { + const open = /]*)?>/i.exec(out) + // Body-less fragments receive the rows at the end, where the HTML parser + // has already synthesized a body. + out = open === null ? `${out}${body}` : splice(out, open.index + open[0].length, body) + } + return out +} diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 2cbd285856..ffe5b4648d 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -15,7 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest' 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' +import HttpServer, { renderIndexInjections } from '../src/index.ts' let root: string | undefined let context: Context | undefined @@ -200,6 +200,54 @@ describe('real Loader composition', () => { await expect(request(port, '/probe')).rejects.toThrow() }) + it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => { + const loaded = await loadComposition() + const server = loaded.webServer + let flag = 'dark' + loaded.on('webserver/index-inject', (table) => { + table.push( + { kind: 'script', placement: 'head', text: 'window.__Q__=1' }, + { kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=' }, + { kind: 'global', name: '__DSH_BOOT__', value: { rev: '' } }, + { kind: 'style', text: 'body{margin:0}' }, + { kind: 'html', placement: 'head', html: '' }, + { kind: 'script', placement: 'body', text: `window.__P__=${JSON.stringify(flag)}` }, + ) + }) + + const html = server.renderIndex('shell') + // Head rows land right after the opening head tag in table order; the body + // row lands right after the opening body tag. + const order = [ + '', + '', + '', + 'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}', + '', + '', + '', + '', + 'shell', + ].map(part => html.indexOf(part)) + expect(order).toEqual([...order].sort((a, b) => a - b)) + expect(order.every(at => at !== -1)).toBe(true) + + // Fresh collection per render: the listener reads live state at emit time. + flag = 'light' + expect(server.renderIndex('')).toContain('window.__P__="light"') + + // Raw taps still run, over the already-rendered rows. + const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2')) + expect(server.renderIndex('')).toContain('window.__Q__=2') + untap() + + // Tag-less fragments: head rows prepend, body rows append. + expect(renderIndexInjections('

x
', [ + { kind: 'script', placement: 'head', text: 'H' }, + { kind: 'script', placement: 'body', text: 'B' }, + ])).toBe('
x
') + }) + it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => { const first = await loadComposition() const takenPort = first.webServer.port From d4fd03ae539b3bfb12a811efce457fc00cb15282 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:16:00 +0800 Subject: [PATCH 151/180] chore(docs): classify IndexInjection and regenerate the catalog surfaces --- 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 | 1 + docs/event-producer-consumer.zh.md | 1 + docs/subsystems/client-modules.i18n.yaml | 4 +- docs/subsystems/client-modules.md | 4 +- docs/subsystems/client-modules.zh.md | 4 +- docs/subsystems/web-server.i18n.yaml | 4 +- docs/subsystems/web-server.md | 41 ++++++++++++++++++- docs/subsystems/web-server.zh.md | 41 ++++++++++++++++++- .../extensions/tool-cordis/src/api-catalog.ts | 32 ++++++++++++++- scripts/gen-cordis-catalog.ts | 2 + 14 files changed, 128 insertions(+), 18 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 6cdc1e42ee..e3de095ad5 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: 5a265c583382b846507f7aae6f59be4341d0a823 -config-catalog.zh.md: d410d383bf7cbe576227edf792f92bb019ad043a +config-catalog.md: d25843091d453687bad5035c49bcb200cb03fff2 +config-catalog.zh.md: 1cc3002799afc10ca84f74044c7b28f91490ffa3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5a265c5833..d25843091d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -863,7 +863,7 @@ export interface Config { } ``` -Source: [`packages/host/webserver/src/index.ts:45`](../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d410d383bf..1cc3002799 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -865,7 +865,7 @@ export interface Config { } ``` -来源:[`packages/host/webserver/src/index.ts:45`](../packages/host/webserver/src/index.ts) +来源:[`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 5fdbf36ed6..77c2dd6536 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: d75d64b58b041c117bf2eaa1612c1b4683378a00 -event-producer-consumer.zh.md: adfc11782e179077b17199fc4ea6e8f52b3c465b +event-producer-consumer.md: d3901b1ddc7d99d4960324ab7459388fa4612bbe +event-producer-consumer.zh.md: 346a293a226719f781a9587eb0adefcf3f6bed08 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d75d64b58b..d3901b1ddc 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -57,6 +57,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../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 adfc11782e..346a293a22 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -59,6 +59,7 @@ | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index 130e99d258..4c8f701de2 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: 52fa4ef73766e8e7e7601e604f78aded9940328f -client-modules.zh.md: e830349be77000759684a76b2834478a803e3e11 +client-modules.md: 81c85e2f0eb1d7248459f6ca092ed89f602e8415 +client-modules.zh.md: 37b8acc0d6d335802333100b5e55e709153911da diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index 52fa4ef737..81c85e2f0e 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -82,7 +82,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.clientModules` — `ClientModuleRegistry` -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). +The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. 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 /** @@ -122,5 +122,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void onGraphChanged(listener: () => void): () => void ``` -Source: [`packages/client/modules/src/index.ts:295`](../../packages/client/modules/src/index.ts) +Source: [`packages/client/modules/src/index.ts:282`](../../packages/client/modules/src/index.ts) diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index e830349be7..37b8acc0d6 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -82,7 +82,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.clientModules` — `ClientModuleRegistry` -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). +The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. 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 /** @@ -122,5 +122,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void onGraphChanged(listener: () => void): () => void ``` -Source: [`packages/client/modules/src/index.ts:295`](../../packages/client/modules/src/index.ts) +Source: [`packages/client/modules/src/index.ts:282`](../../packages/client/modules/src/index.ts) diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 8ed53c1fa7..519c6e7043 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-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/web-server.md -web-server.md: 414863d88cdc34163882cdc3dfe8dc7c1471ac77 -web-server.zh.md: d756192ce733a3fe79c9eaf44e5d556c4bac90a8 +web-server.md: 7dd700d24f63d31f34d8711069477414292e3bf6 +web-server.zh.md: ca67ff231e9aad9144fa5203ee46435f636a93da diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 414863d88c..7dd700d24f 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -102,7 +102,46 @@ tapIndex(transform: (html: string) => string): () => void * @returns the transformed body. */ applyIndexTaps(html: string): string + +/** + * Gather the structured injection table: one `webserver/index-inject` emit, + * every subscriber pushes its current rows. Fresh per call, so subscribers + * read live state (module graph, theme preference) at emit time. + * @returns rows in subscriber activation order. + */ +collectIndexInjections(): IndexInjection[] + +/** + * Render one index.html body: the structured injection table first, then + * the raw `tapIndex` transforms over the result. + * @param html - the raw index.html body. + * @returns the transformed body. + */ +renderIndex(html: string): string ``` -Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:73`](../../packages/host/webserver/src/index.ts) + + + +### `webserver/*` events + + + +#### `webserver/index-inject` — emit + +Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. + +```ts cordis-catalog +/** + * Collect the structured index injection table. Emitted on every index + * render and every worker boot-payload request; listeners push their + * current rows, so a row's data is read fresh at emit time. + * @param table - Mutable row table; listeners append in activation order. + * @mode emit + */ +'webserver/index-inject'(table: IndexInjection[]): void +``` + +Source: [`packages/host/webserver/src/index.ts:34`](../../packages/host/webserver/src/index.ts) diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index d756192ce7..ca67ff231e 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -102,7 +102,46 @@ tapIndex(transform: (html: string) => string): () => void * @returns the transformed body. */ applyIndexTaps(html: string): string + +/** + * Gather the structured injection table: one `webserver/index-inject` emit, + * every subscriber pushes its current rows. Fresh per call, so subscribers + * read live state (module graph, theme preference) at emit time. + * @returns rows in subscriber activation order. + */ +collectIndexInjections(): IndexInjection[] + +/** + * Render one index.html body: the structured injection table first, then + * the raw `tapIndex` transforms over the result. + * @param html - the raw index.html body. + * @returns the transformed body. + */ +renderIndex(html: string): string ``` -Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:73`](../../packages/host/webserver/src/index.ts) + + + +### `webserver/*` events + + + +#### `webserver/index-inject` — emit + +Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. + +```ts cordis-catalog +/** + * Collect the structured index injection table. Emitted on every index + * render and every worker boot-payload request; listeners push their + * current rows, so a row's data is read fresh at emit time. + * @param table - Mutable row table; listeners append in activation order. + * @mode emit + */ +'webserver/index-inject'(table: IndexInjection[]): void +``` + +Source: [`packages/host/webserver/src/index.ts:34`](../../packages/host/webserver/src/index.ts) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 708c179df5..a20c0b9aff 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -459,8 +459,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'clientModules', - summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap.', - description: '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).', + summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows.', + description: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. 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).', methods: [ { signature: 'graph(): WebBootGraph', @@ -2217,6 +2217,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'html', description: 'the raw index.html body.' }], returns: 'the transformed body.', }, + { + signature: 'collectIndexInjections(): IndexInjection[]', + description: 'Gather the structured injection table: one `webserver/index-inject` emit, every subscriber pushes its current rows. Fresh per call, so subscribers read live state (module graph, theme preference) at emit time.', + parameters: [], + returns: 'rows in subscriber activation order.', + }, + { + signature: 'renderIndex(html: string): string', + description: 'Render one index.html body: the structured injection table first, then the raw `tapIndex` transforms over the result.', + parameters: [{ name: 'html', description: 'the raw index.html body.' }], + returns: 'the transformed body.', + }, ], }, { @@ -2685,6 +2697,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.', parameters: [{ name: 'exec', description: 'the execution object that traversed the pipeline.' }, { name: 'result', description: 'a deep-frozen snapshot of the final returned result.' }], }, + { + name: 'webserver/index-inject', + mode: 'emit', + signature: '\'webserver/index-inject\'(table: IndexInjection[]): void', + summary: 'Collect the structured index injection table.', + description: 'Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row\'s data is read fresh at emit time.', + parameters: [{ name: 'table', description: 'Mutable row table; listeners append in activation order.' }], + }, { name: 'workflow/agent-end', mode: 'emit', @@ -3297,6 +3317,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InboxTarget', declaration: 'export type InboxTarget = \'next-turn\' | \'next-step\';', }, + { + name: 'IndexInjection', + declaration: 'export type IndexInjection = {\n kind: \'global\';\n name: string;\n value: unknown;\n} | {\n kind: \'script\';\n placement: IndexInjectionPlacement;\n text: string;\n} | {\n kind: \'script-src\';\n placement: IndexInjectionPlacement;\n src: string;\n} | {\n kind: \'style\';\n text: string;\n} | {\n kind: \'html\';\n placement: IndexInjectionPlacement;\n html: string;\n};', + }, + { + name: 'IndexInjectionPlacement', + declaration: 'export type IndexInjectionPlacement = \'head\' | \'body\';', + }, { name: 'InvariantFailure', declaration: 'export type InvariantFailure = (message: string) => never;', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a088890413..4b1f71c5e9 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -184,6 +184,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'system-prompt': 'system-prompt.md', 'session-telemetry': 'session-telemetry.md', 'tools': 'tools.md', + 'webserver': 'web-server.md', 'workflow': 'workflow.md', } @@ -480,6 +481,7 @@ export const LINK_MAP: Readonly> = { PresetSpec: 'permission-presets.md', InvariantInstaller: 'invariants.md', WebRoute: 'web-server.md', + IndexInjection: 'web-server.md', StorageBackend: 'storage.md', StorageForms: 'storage.md', Domain: 'storage.md', From 3ca4997cd27b9fc352746a3d7bfe3b2a4e4ca4fb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:16:17 +0800 Subject: [PATCH 152/180] test: cover gateway rollback and theme fallback branches --- .../api/gateway/tests/gateway.client.spec.ts | 70 +++++++++++++++++++ .../client/ui-theme/tests/host.client.spec.ts | 9 +++ 2 files changed, 79 insertions(+) diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 59add544ea..c6d4a03eb3 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -458,6 +458,76 @@ describe('Client Typert API', () => { await disposeContext() }) + it('unwinds an already-installed namespace when a later namespace fails to install', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...probe } = directDescriptor() + const vault: InvocationDescriptor = { + ...probe, + id: '@fixture/vault#vault/seal', + service: 'vault', + namespace: 'vault', + method: 'seal', + } + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'seal') throw new Error('fixture later-namespace failure') + return defineProperty(target, key, attributes) + }) + try { + await expect(ctx.remote.$mount({ package: '@fixture/two-namespaces', descriptors: [probe, vault] })) + .rejects.toThrow('fixture later-namespace failure') + } finally { + spy.mockRestore() + } + + expect((ctx.remote as unknown as Record).probe).toBeUndefined() + expect((ctx.remote as unknown as Record).vault).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + }) + + it('rolls back an earlier scoped projection when a later descriptor fails to install', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...direct } = directDescriptor() + const failing: InvocationDescriptor = { + ...direct, + id: '@fixture/probe#probe/archive', + method: 'archive', + } + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'archive') throw new Error('fixture trailing failure') + return defineProperty(target, key, attributes) + }) + try { + await expect(ctx.remote.$mount({ + package: '@fixture/scoped-then-failing', + descriptors: [contextDescriptor(), failing], + })).rejects.toThrow('fixture trailing failure') + } finally { + spy.mockRestore() + } + + expect((ctx.remote as unknown as Record).probe).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + }) + + it('keeps a namespace another contribution still populates when a group leaves', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { renamed: true } }) + const ctx = await bench(call) + const { scope: _scope, ...direct } = directDescriptor() + const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-owner', descriptors: [direct] }) + const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-owner', descriptors: [contextDescriptor()] }) + + await disposeDirect() + // The namespace survives its first group: the second contribution still owns methods on it. + const surviving = ctx.get('remote.probe') as unknown as Record | undefined + expect(surviving).toBeDefined() + expect(surviving?.create).toBeUndefined() + await disposeScoped() + expect(ctx.get('remote.probe')).toBeUndefined() + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() diff --git a/packages/client/ui-theme/tests/host.client.spec.ts b/packages/client/ui-theme/tests/host.client.spec.ts index 18f6ed42b8..692158cba0 100644 --- a/packages/client/ui-theme/tests/host.client.spec.ts +++ b/packages/client/ui-theme/tests/host.client.spec.ts @@ -62,4 +62,13 @@ describe('ui-theme host', () => { await ctx.plugin({ apply }).await() expect(scriptText(collect(ctx)[0])).toContain('const preference = "system"') }) + + it('falls back to the schema default while the theme namespace holds no section', async () => { + // A settings provider whose namespace read comes back empty (registration + // still pending or a provider without schema defaults). + const ctx = new Context() + ctx.provide('settings', { register: () => () => {}, get: () => undefined } as never) + await ctx.plugin({ apply }).await() + expect(scriptText(collect(ctx)[0])).toContain('const preference = "system"') + }) }) From 10f9f506e2268d5f5a441ad8450625890cc3d956 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:44:17 +0800 Subject: [PATCH 153/180] fix(web): stand down prefetch only when the transport owns bundle bytes --- packages/client/web/src/boot.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/client/web/src/boot.ts b/packages/client/web/src/boot.ts index 5495a86379..9aee3b0b59 100644 --- a/packages/client/web/src/boot.ts +++ b/packages/client/web/src/boot.ts @@ -51,7 +51,10 @@ export class AppWebEntry { throw new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing') } // A pre-injected transport (the worker preview page) owns bundle bytes; - // its loadBundle is the default and explicit seams still win. + // its loadBundle is the default and explicit seams still win. The global + // is `ClientTransportHooks`, owned by @deepseek-ai/dsh-client-connection; + // this structural slice reads one optional member without adding a + // package edge. const transport = (globalThis as { __DSH_TRANSPORT__?: { loadBundle?: ClientModuleCreateOptions['loadBundle'] } }).__DSH_TRANSPORT__ @@ -92,9 +95,13 @@ export class AppWebEntry { /** Prefetch stage-one bundles; their import path owns any eventual failure. */ private async prefetchImmediateTier(): Promise { - // A pre-injected transport owns bundle bytes; HTTP prefetch against the - // static deployment answers nothing. - if ((globalThis as { __DSH_TRANSPORT__?: unknown }).__DSH_TRANSPORT__ !== undefined) return + // A transport carrying loadBundle owns the bundle bytes; HTTP prefetch + // against its static deployment answers nothing. A transport without + // loadBundle leaves bundles on HTTP, prefetch included. + const transport = (globalThis as { + __DSH_TRANSPORT__?: { loadBundle?: unknown } + }).__DSH_TRANSPORT__ + if (transport?.loadBundle !== undefined) return await Promise.all(this.manifest.plugins .filter(row => row.immediately) .map(row => this.modules.prefetch(row.id).catch((_prefetchError: unknown) => { From 97f4d4608baf97949dbc0ba07db23289bb26fedb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:44:23 +0800 Subject: [PATCH 154/180] test(gateway): pin the namespace atomic-visibility guarantee --- packages/api/gateway/tests/gateway.client.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index c6d4a03eb3..99fe477bf2 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -528,6 +528,21 @@ describe('Client Typert API', () => { expect(ctx.get('remote.probe')).toBeUndefined() }) + it('unparks a namespace dependent only after its contribution methods exist', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...direct } = directDescriptor() + let observed: string | undefined + // Parked before the mount: the unpark moment is the observation — the + // atomic-visibility guarantee says the service never appears methodless. + const parked = ctx.inject(['remote.probe'], (probeCtx) => { + observed = typeof (probeCtx.get('remote.probe') as { create?: unknown } | undefined)?.create + }) + const dispose = await ctx.remote.$mount({ package: '@fixture/atomic-visibility', descriptors: [direct] }) + await parked + expect(observed).toBe('function') + await dispose() + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() From d5829397832416182d0fac952aa9a535970e8eb6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:44:29 +0800 Subject: [PATCH 155/180] docs(web): align injection-surface JSDoc, notes, and bilingual READMEs --- .../2026-08-19-web-index-injection-table.i18n.yaml | 4 ++-- .../architecture/2026-08-19-web-index-injection-table.md | 2 +- .../architecture/2026-08-19-web-index-injection-table.zh.md | 2 +- docs/subsystems/web-server.i18n.yaml | 4 ++-- docs/subsystems/web-server.md | 5 +++-- docs/subsystems/web-server.zh.md | 5 +++-- packages/extensions/cordis-host-runner/src/sandbox.ts | 6 +++++- packages/extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/host/frontend-static/README.i18n.yaml | 4 ++-- packages/host/frontend-static/README.md | 2 +- packages/host/frontend-static/README.zh.md | 2 +- packages/host/webserver/README.i18n.yaml | 4 ++-- packages/host/webserver/README.md | 2 +- packages/host/webserver/README.zh.md | 2 +- packages/host/webserver/src/index.ts | 5 +++-- packages/host/webserver/src/injections.ts | 2 +- 16 files changed, 30 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml index 1c228865dc..90d1f96dff 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages 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-19-web-index-injection-table.md -2026-08-19-web-index-injection-table.md: a4c21974ef6770320b61cd7c2b19fc20372e8111 -2026-08-19-web-index-injection-table.zh.md: 4ccfa8afe6353e9af7e341ce73f5fb3213257ba2 +2026-08-19-web-index-injection-table.md: 9ed02aa94cd318d107a32802d8723652e6b10ea2 +2026-08-19-web-index-injection-table.zh.md: 8ad036766faa14071b20da12ef907ab012cae23f diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md index a4c21974ef..9ed02aa94c 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md @@ -20,7 +20,7 @@ One table, two renderers: the served form's `webServer.renderIndex(html)` render - client-modules and ui-theme no longer regex-edit HTML; the worker's `readBootPayload` service-poking (`clientModules`, `settings`, theme constants through `loader.load`) is deleted; the page-side `installModuleLoaderFacade`, `applyBootTheme`, and `PARSER_PRELOAD_IDS` re-implementations retire. - Ordering: across subscribers, subscription order (same as the old tap order); within one subscriber, push order — modules itself guarantees queue → preloads → global. -- The served rendering of the manifest global changed from `window.__DSH_BOOT__ =` to `globalThis["__DSH_BOOT__"] =`; snapshot expectations carrying that text need re-recording. +- The served rendering of the manifest global changed from `window.__DSH_BOOT__ =` to `globalThis["__DSH_BOOT__"] =`; no committed snapshot expectation carries that text, so none needed re-recording. - New model-visible or page-visible boot inputs extend the row union; no new tap consumers. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md index 4ccfa8afe6..8ad036766f 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.zh.md @@ -20,7 +20,7 @@ Web 壳的启动 HTML 需要三类注入:client-modules 的引导协议(`__M - client-modules 与 ui-theme 不再各自正则改 HTML;worker 侧 `readBootPayload` 的 `ctx.get` 手掏(clientModules、settings、theme 常量 loader.load)删除;页面侧 `installModuleLoaderFacade`、`applyBootTheme`、`PARSER_PRELOAD_IDS` 三份重抄退役。 - 顺序语义:跨订阅方按订阅注册顺序(与旧 tap 顺序一致),单订阅方内按 push 顺序;modules 自己保证 队列→preload→全局 三行有序。 -- `__DSH_BOOT__` 的 served 渲染文本从 `window.__DSH_BOOT__ =` 变为 `globalThis["__DSH_BOOT__"] =`;含此文本的快照期望需重录。 +- `__DSH_BOOT__` 的 served 渲染文本从 `window.__DSH_BOOT__ =` 变为 `globalThis["__DSH_BOOT__"] =`;已核实无已提交快照期望含此文本,无需重录。 - 新的模型可见/页面可见注入一律走行类型扩展,不再新增 tap 消费者。 ## Alternatives considered diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 519c6e7043..f04a35b545 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-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/web-server.md -web-server.md: 7dd700d24f63d31f34d8711069477414292e3bf6 -web-server.zh.md: ca67ff231e9aad9144fa5203ee46435f636a93da +web-server.md: 70bc0a2631418c102bdb79c1df29a73df5edf30a +web-server.zh.md: 870c9209dcfacd191da408be2e195edc047ab25d diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 7dd700d24f..70bc0a2631 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -88,8 +88,9 @@ registerUpgrade(route: WebUpgradeRoute): () => void registerFallback(handler: WebRoute['handler']): () => void /** - * Register an index.html transform, applied by the fallback owner to every - * index response ({@link applyIndexTaps}) in registration order. + * Register a raw-HTML index transform, the escape hatch for markup no + * {@link IndexInjection} row expresses: {@link renderIndex} applies taps in + * registration order after rendering the structured rows. * @param transform - pure html-to-html function. * @returns the disposer removing the transform. */ diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index ca67ff231e..870c9209dc 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -88,8 +88,9 @@ registerUpgrade(route: WebUpgradeRoute): () => void registerFallback(handler: WebRoute['handler']): () => void /** - * Register an index.html transform, applied by the fallback owner to every - * index response ({@link applyIndexTaps}) in registration order. + * Register a raw-HTML index transform, the escape hatch for markup no + * {@link IndexInjection} row expresses: {@link renderIndex} applies taps in + * registration order after rendering the structured rows. * @param transform - pure html-to-html function. * @returns the disposer removing the transform. */ diff --git a/packages/extensions/cordis-host-runner/src/sandbox.ts b/packages/extensions/cordis-host-runner/src/sandbox.ts index f359801af1..fc0a771e4f 100644 --- a/packages/extensions/cordis-host-runner/src/sandbox.ts +++ b/packages/extensions/cordis-host-runner/src/sandbox.ts @@ -201,6 +201,10 @@ export function parseErrorMessage(half: 'code.host' | 'code.client', context: st * unparseable code — and `vm.Script` is only the best-effort prettifier: on a * Node host its failure carries the source-line-and-caret prelude the * teaching text builds on, and where the vm is a stub the message stays bare. + * The two parsers' syntax faces differ at the margin (`new.target` parses in + * a function body but not at the vm wrapper's top level), an accepted cost of + * a vm-free gate; and under a page CSP without `'unsafe-eval'`, `new Function` + * throws `EvalError`, which propagates unwrapped. * @param code - the model-written function body. * @param half - which define argument carried it, for the error text. * @throws when the body does not parse, with the offending line and a teaching hint. @@ -209,7 +213,7 @@ export function precheckCode(code: string, half: 'code.host' | 'code.client'): v const wrapped = `(async () => {\n${code}\n})()` try { // Compile-only: constructing the function parses the source and runs nothing. - // eslint-disable-next-line @typescript-eslint/no-implied-eval -- parse gate over model-written code; nothing is invoked + // oxlint-disable-next-line typescript/no-implied-eval -- parse gate over model-written code; nothing is invoked new Function(wrapped) } catch (error) { if (!isSyntaxError(error)) throw error diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a20c0b9aff..bf0f6a9e79 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2207,7 +2207,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'tapIndex(transform: (html: string) => string): () => void', - description: 'Register an index.html transform, applied by the fallback owner to every index response (applyIndexTaps) in registration order.', + description: 'Register a raw-HTML index transform, the escape hatch for markup no IndexInjection row expresses: renderIndex applies taps in registration order after rendering the structured rows.', parameters: [{ name: 'transform', description: 'pure html-to-html function.' }], returns: 'the disposer removing the transform.', }, diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index 0514c3889e..2255848c81 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. 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/frontend-static/README.md -README.md: e53a369384fb8d7fe020e99da0d2cacca59e8afd -README.zh.md: 7018d80182d477f2bdff7b1d8510971dc98c71d5 +README.md: fb9ab0c9fea6fdaffdc08ecb63b17ffff9ff1e16 +README.zh.md: b0fd0f3aac7e54ccd50f8e87a186fd080c925610 diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index e53a369384..fb9ab0c9fe 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -SPA dist server for the Web shell: a function plugin (config `{distIndex}`) that claims the [webserver](../webserver/README.md)'s single fallback seat and serves the built frontend directory with the shell's 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 `application/octet-stream`, and non-GET/HEAD without a matching named route is 405. Every index response runs through the webserver's registered index taps (`applyIndexTaps`), which is how the boot manifest reaches the page. `distIndex` is an assembly fact of the composing application: [`dsh-web-app`](../../bundle/web-app/README.md) resolves it through the frontend package's exports and mounts this plugin; a deployment never hardcodes it. +SPA dist server for the Web shell: a function plugin (config `{distIndex}`) that claims the [webserver](../webserver/README.md)'s single fallback seat and serves the built frontend directory with the shell's 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 `application/octet-stream`, and non-GET/HEAD without a matching named route is 405. Every index response is rendered through the webserver's `renderIndex` — structured injection rows first, then the raw index taps — which is how the boot manifest reaches the page. `distIndex` is an assembly fact of the composing application: [`dsh-web-app`](../../bundle/web-app/README.md) resolves it through the frontend package's exports and mounts this plugin; a deployment never hardcodes it. The fallback seat is single-owner (a second claim throws) and effect-scoped: disposing the plugin's fiber releases the seat, after which the unclaimed webserver answers 404. diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index 7018d80182..b0fd0f3aac 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.md) 的唯一回退席位,并按壳层锁定的语义服务已构建的前端目录——越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 `application/octet-stream` 提供,GET/HEAD 之外的方法在没有匹配的具名路由时返回 405。每个 index 响应都会经过 webserver 已注册的 index 转换器(`applyIndexTaps`),启动 manifest(元数据清单)就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。 +Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.md) 的唯一回退席位,并按壳层锁定的语义服务已构建的前端目录——越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 `application/octet-stream` 提供,GET/HEAD 之外的方法在没有匹配的具名路由时返回 405。每个 index 响应都经 webserver 的 `renderIndex` 渲染——先结构化注入行、后原始 index 转换器——启动 manifest(元数据清单)就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。 回退席位只有单一所有者(第二次占据会抛错),并受 effect 作用域约束:dispose(资源释放)插件的 fiber 会释放席位,此后无人占据的 webserver 回答 404。 diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 591ca04216..a9301ea812 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: 7927c791a809ad272edce1310b3c50bbe30720a5 -README.zh.md: 82252adb3945f22c83abddbd3bbdd11556383add +README.md: b6424262305b062f4c57774c446ac1d50e490a03 +README.zh.md: cdeeb26159b98048f8ba5e821e7d6e733f447351 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 7927c791a8..b642426230 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `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-host-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. +Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `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-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. Index startup inputs are structured rows: `collectIndexInjections()` gathers a fresh `IndexInjection` table over one `webserver/index-inject` emit per call, and `renderIndex(html)` renders the rows into an index.html body before applying the raw `tapIndex(transform)` transforms in registration order (`applyIndexTaps(html)`, the escape hatch for markup no row expresses); the fallback handler calls `renderIndex` on every index response, and a static deployment ships the same rows over its boot payload, rendering with the exported `renderIndexInjections`. `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). 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. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 82252adb39..cdeeb26159 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-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 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。 +Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。index 的启动输入是结构化行:`collectIndexInjections()` 每次调用经一次 `webserver/index-inject` emit 现收一张全新的 `IndexInjection` 表,`renderIndex(html)` 先把行渲染进 index.html 响应体,再按注册顺序应用原始的 `tapIndex(transform)` 转换(`applyIndexTaps(html)`,行无法表达的标记的逃生口);fallback handler 在每次 index 响应时调用 `renderIndex`,静态部署则把同一批行经 boot 载荷下发,用导出的 `renderIndexInjections` 渲染。`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`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 94aba72382..2257e79623 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -145,8 +145,9 @@ export class WebServer extends Service { } /** - * Register an index.html transform, applied by the fallback owner to every - * index response ({@link applyIndexTaps}) in registration order. + * Register a raw-HTML index transform, the escape hatch for markup no + * {@link IndexInjection} row expresses: {@link renderIndex} applies taps in + * registration order after rendering the structured rows. * @param transform - pure html-to-html function. * @returns the disposer removing the transform. */ diff --git a/packages/host/webserver/src/injections.ts b/packages/host/webserver/src/injections.ts index cb1e5336a5..dc38213e85 100644 --- a/packages/host/webserver/src/injections.ts +++ b/packages/host/webserver/src/injections.ts @@ -23,7 +23,7 @@ export type IndexInjection = * loader resolves worker-only URLs such as `/plugins/...`). */ | { kind: 'script-src'; placement: IndexInjectionPlacement; src: string } - /** A `