From 7639f4cb68e32102dce67a7caf30260cf5ff104f Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Wed, 29 Jul 2026 14:12:01 +0800
Subject: [PATCH 001/144] feat(web): answerable ask_user_question flow with
toolview verdict row
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pending question now owns exactly two surfaces: the redesigned
QuestionComposer takeover (footer pager, checkbox multi-select,
always-visible custom input, locale-injected bilingual chrome) collects
the answers, and a dedicated ask_user_question toolview row reports the
interaction outcome — waiting, N/M answered, cancelled (ASK_CANCELLED),
or interrupted with stopped semantics (ASK_ABORTED). PendingCard narrows
to approval waits only. Toolview leading icons and the hover chevron
unify on the tertiary label color, the checklist glyph matches the
14px figma extract, and dev-watch registers CSS modules so css-only
edits rebuild.
---
...29-ask-question-web-presentation.i18n.yaml | 6 +
...026-07-29-ask-question-web-presentation.md | 45 +++
...-07-29-ask-question-web-presentation.zh.md | 45 +++
docs/event-producer-consumer.md | 2 +-
packages/client/tsdown.client.ts | 5 +-
.../ui-conversation/src/client/apply.ts | 4 +
.../src/client/chat/ChatView.tsx | 5 +-
.../src/client/chat/PendingCard.tsx | 27 +-
.../src/client/chat/ToolRow.module.css | 10 -
.../src/client/chat/ToolRow.tsx | 5 +-
.../src/client/toolviews/ask-question-row.tsx | 94 ++++++
.../src/client/toolviews/todo-row.module.css | 58 ----
.../src/client/toolviews/todo-row.tsx | 63 ++--
.../tests/ask-question-row.spec.tsx | 130 ++++++++
.../ui-conversation/tests/chat-apply.spec.tsx | 6 +-
.../tests/coverage-tails.spec.tsx | 14 +-
.../ui-conversation/tests/todo-panel.spec.tsx | 32 +-
.../client/ui-primitives/src/icons/index.tsx | 39 ++-
.../client/ui-primitives/tests/icons.spec.tsx | 4 +-
packages/client/ui-question/README.i18n.yaml | 6 +-
packages/client/ui-question/README.md | 2 +
packages/client/ui-question/README.zh.md | 2 +
packages/client/ui-question/package.json | 4 +-
.../src/client/QuestionComposer.module.css | 280 ++++++++++--------
.../src/client/QuestionComposer.tsx | 206 +++++++------
.../ui-question/src/client/contract/slots.ts | 30 +-
.../client/ui-question/src/client/index.ts | 50 +++-
.../client/ui-question/src/client/locales.ts | 39 +++
.../ui-question/tests/browser-plugin.spec.ts | 52 +++-
.../tests/question-composer.spec.tsx | 48 +--
packages/client/ui-question/tsconfig.json | 3 +
pnpm-lock.yaml | 3 +
32 files changed, 869 insertions(+), 450 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
create mode 100644 packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
delete mode 100644 packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
create mode 100644 packages/client/ui-conversation/tests/ask-question-row.spec.tsx
create mode 100644 packages/client/ui-question/src/client/locales.ts
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
new file mode 100644
index 0000000000..6954c289bd
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.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-29-ask-question-web-presentation.md
+2026-07-29-ask-question-web-presentation.md: 90eeb3cdcc1a851b7d5e184c0f31cbccd82cbf55
+2026-07-29-ask-question-web-presentation.zh.md: 5bb19d3a68dc0510ea766d7a22abdc1cff9c326a
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
new file mode 100644
index 0000000000..90eeb3cdcc
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
@@ -0,0 +1,45 @@
+# Agent Note: Ask-question Web presentation
+
+Status: implemented
+
+English | [中文](2026-07-29-ask-question-web-presentation.zh.md)
+
+## Problem
+
+The Web GUI could already collect answers through the `QuestionComposer` composer takeover, but the transcript around it was wrong on three counts. A pending question rendered twice: once as the composer takeover and once as the read-only `PendingCard` placeholder that predates the takeover. A settled `ask_user_question` call rendered as the generic "Tool call" row dumping raw args JSON, so the two composer verdicts — the user dismissing the whole set (`ASK_CANCELLED`) and a turn interrupt landing while the question was pending (`ASK_ABORTED`) — both read as anonymous red-dot failures. And the composer's own chrome copy (pager, buttons, placeholders, validation feedback) was hardcoded Chinese while the surrounding client is bilingual through `dsh-client-locale`.
+
+Separately, the composer visuals had drifted from the current design: an expand-to-open custom answer entry, no multi-select affordance beyond a trailing check, header-mounted paging, and a `(可多选)` title-suffix convention parsed out of model text.
+
+## Decision
+
+A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `conversation.chat.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrows to `PendingWait<'approval'>` and `ChatView` filters the pending list to approval waits, so the placeholder card now exists only for the approval takeover still on the roadmap.
+
+The composer redesign moves paging into the footer next to the actions, renders multi-select options with explicit checkboxes, keeps single-select numbered rows, and replaces the expand-to-open custom entry with an always-visible custom input row (textarea for optionless questions). The `parseQuestionTitle` multi-select suffix convention is deleted; `multi_select` is already structured metadata, so the title renders verbatim.
+
+Composer chrome copy becomes bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry a namespace-bound translator plus the locale snapshot as a hooks-compartment source through the slot inject face, so a locale flip re-renders a mounted composer. Validation feedback is stored as a dictionary key and re-translated on flip; carrier failure messages and all model-authored question/option text render verbatim.
+
+Two adjacent fixes ride along. All generic toolview leading icons (and the hover chevron) now inherit the single tertiary label color — the others-variant secondary override and the separate chevron color rule are deleted, leaving only the intentional cordis business-primary accent. And the client dev-watch bundler registers each CSS module with `addWatchFile`, because the virtual-module indirection previously hid css-only edits from the watcher.
+
+## Alternatives considered
+
+**Keep rendering questions through `PendingCard`.** Rejected: the card was a read-only placeholder from before the takeover existed, so a pending question showed the same content twice with one copy not answerable. The toolview row plus takeover covers both the transcript record and the collection surface.
+
+**Show the questions or answers inline in the transcript row.** Rejected: the composer takeover owns question rendering and answer collection, and the row convention (`todo_write`) is one line with details in the panel. The row therefore reports only the outcome, mirroring how the todo row reports counts while the panel owns the list.
+
+**Render `ASK_CANCELLED`/`ASK_ABORTED` through the generic error shape.** Rejected: dismissal is the user's own deliberate action and an interrupt is the shared stop gesture; both are expected outcomes, not tool failures. Naming the verdict (and keeping amber stopped semantics for the abort) matches how interrupted tool calls read elsewhere.
+
+**Translate the row verdicts now.** Deferred by explicit product decision: the row's `waiting`/`answered`/`cancelled`/`interrupted` strings stay English for this change; the composer chrome i18n landed because its Chinese-only copy was already wrong for the en locale.
+
+**Keep the title-suffix multi-select convention.** Rejected: `multi_select` is structured request metadata and the checkbox affordance now carries the signal, so parsing `(可多选)` out of model text was a fragile duplicate channel.
+
+## Consequences
+
+`ask_user_question` and `todo_write` now demonstrate the intended toolview pattern: compose `ToolRow`, summarize from call args or result JSON with shape-checked fallbacks, and register through the keyed slot. The bespoke `todo-row.module.css` is gone.
+
+The row verdict strings are the one remaining hardcoded-English surface of the question flow; localizing them is deferred follow-up. `PendingCard` remains a visible-but-not-answerable approval placeholder until the approval composer takeover ships.
+
+`ui-question` gains a `dsh-client-locale` dependency and an inject face where it previously had none; its contract (`QuestionComposerInjected`) lives with the consumer in `contract/slots.ts`.
+
+## Verification
+
+`ui-conversation` tests pin the row's waiting/answered/skipped/cancelled/interrupted/fallback matrix, the approval-only pending filter, and the slot registration; `ui-question` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths.
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
new file mode 100644
index 0000000000..5bb19d3a68
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
@@ -0,0 +1,45 @@
+# Agent Note:Ask-question Web 呈现
+
+Status: implemented
+
+[English](2026-07-29-ask-question-web-presentation.md) | 中文
+
+## 问题
+
+Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答,但其周边的会话记录呈现在三个方面是错的。待回答的问题会渲染两次:一次是输入区接管,一次是早于接管存在的只读 `PendingCard` 占位卡片。已结算的 `ask_user_question` 调用渲染为通用 "Tool call" 行并直接倾倒原始 args JSON,因此两种输入区裁决 —— 用户放弃整组问题(`ASK_CANCELLED`)与问题待回答期间轮次被打断(`ASK_ABORTED`)—— 都显示为无名的红点失败。而且输入区自身的界面文案(分页、按钮、占位符、校验反馈)是硬编码中文,而周边客户端已通过 `dsh-client-locale` 实现双语。
+
+另外,输入区视觉也偏离了当前设计:自定义回答需展开才能输入、多选除尾部对勾外没有可见标识、分页挂在头部、还有从模型文本里解析 `(可多选)` 标题后缀的约定。
+
+## 决定
+
+一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `conversation.chat.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 收窄为 `PendingWait<'approval'>`,`ChatView` 将待处理列表过滤为仅审批等待,占位卡片从此只服务于仍在路线图上的审批接管。
+
+输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。
+
+输入区界面文案实现双语:插件在 `dsh-client-locale` 的 `question` 命名空间下注册中英词典,并通过槽位 inject face 向条目提供绑定命名空间的翻译器和作为 hooks 舱源的 locale 快照,语言切换时已挂载的输入区会重新渲染。校验反馈以词典 key 存储、切换时重新翻译;载体失败消息与所有模型撰写的问题/选项文本原样渲染。
+
+两个相邻修复随行。所有通用 toolview 前导图标(含悬停箭头)现在统一继承三级标签色 —— 删除了 others 变体的二级色覆盖和独立的箭头颜色规则,只保留有意为之的 cordis 业务主色强调。客户端 dev-watch 打包器用 `addWatchFile` 注册每个 CSS 模块,因为虚拟模块间接层此前使仅改 CSS 的编辑对 watcher 不可见。
+
+## 曾考虑的替代方案
+
+**继续通过 `PendingCard` 渲染问题。** 否决:该卡片是接管存在之前的只读占位,导致同一内容显示两份且其中一份不可作答。toolview 行加接管同时覆盖了记录与收集两个面。
+
+**在会话记录行内联显示问题或回答。** 否决:输入区接管拥有问题渲染与回答收集,而行的约定(`todo_write`)是单行、详情在面板。因此行只报告结果,正如 todo 行报告计数而面板拥有列表。
+
+**用通用错误形态渲染 `ASK_CANCELLED`/`ASK_ABORTED`。** 否决:放弃是用户自己的主动操作,打断是共享的停止手势;两者都是预期结果而非工具失败。命名裁决(且中止保持琥珀色 stopped 语义)与其他被打断的工具调用的呈现一致。
+
+**现在就翻译行内裁决文案。** 依明确的产品决定推迟:本次改动中行的 `waiting`/`answered`/`cancelled`/`interrupted` 字符串保持英文;输入区界面文案的国际化落地是因为其仅中文的文案在 en 语言下本就是错的。
+
+**保留标题后缀的多选约定。** 否决:`multi_select` 是结构化请求元数据且复选框标识已承载该信号,从模型文本解析 `(可多选)` 是脆弱的重复通道。
+
+## 后果
+
+`ask_user_question` 与 `todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的槽位注册。专用的 `todo-row.module.css` 已删除。
+
+行内裁决字符串是问题流程仅剩的硬编码英文面;将其本地化是推迟的后续工作。在审批输入区接管交付之前,`PendingCard` 仍是可见但不可操作的审批占位。
+
+`ui-question` 新增 `dsh-client-locale` 依赖和此前没有的 inject face;其契约(`QuestionComposerInjected`)与消费者一起放在 `contract/slots.ts`。
+
+## 验证
+
+`ui-conversation` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted/回退矩阵、仅审批的待处理过滤和槽位注册;`ui-question` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index e9e5bb6e6a..2ab220305c 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -68,7 +68,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `hmr`, `modules`, `webserver` |
| `internal/status` | - | [`agent`](../packages/core/agent) |
-| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` |
+| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-question`, `ui-settings-general` |
| `slots/changed` | `runtime` (`emit`) | - |
| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` |
diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts
index 9b93feae8b..6b004c80b4 100644
--- a/packages/client/tsdown.client.ts
+++ b/packages/client/tsdown.client.ts
@@ -124,9 +124,12 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
- async load(virtualId: string) {
+ async load(this: { addWatchFile?: (id: string) => void }, virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
+ // Virtual modules hide the real file from the watcher; register it so
+ // dev-web rebuilds on a css-only edit.
+ this.addWatchFile?.(fileId)
const source = await readFile(fileId)
const { code, exports: cssExports } = transform({
filename: fileId,
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 62801ca0b8..48dee62787 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -14,6 +14,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
+import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
@@ -187,6 +188,9 @@ export function apply(ctx: Context): void {
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
+ // The ask_user_question row: waiting/answered/cancelled interaction outcome.
+ ctx.plugin(askQuestionToolview)
+
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
index deb7f09f6c..e5d80d52c6 100644
--- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
@@ -361,7 +361,10 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
))}
)}
- {pending.map(item => )}
+ {/* Approval waits only: a pending question already shows as the
+ ask_user_question row (waiting state) plus the composer takeover. */}
+ {pending.filter(item => item.kind === 'approval')
+ .map(item => )}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && }
diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
index b6825aed9a..5a2076fe85 100644
--- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
@@ -1,31 +1,22 @@
-// PendingCard: approval/question placeholder card (visible, not answerable —
-// the composer-takeover approval panel is a P-II item; wire pending semantics
-// already exist so the flow must show them).
+// PendingCard: approval placeholder card (visible, not answerable — the
+// composer-takeover approval panel is a P-II item; wire pending semantics
+// already exist so the flow must show them). Question waits render through
+// the ask_user_question toolview row + the composer takeover instead.
import { memo } from 'react'
-import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
-import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import css from './PendingCard.module.css'
export interface PendingCardProps {
- item: PendingInteraction
+ item: PendingWait<'approval'>
}
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
return (
- {item.kind === 'approval' ? (
- <>
-
等待审批:{item.payload.toolName}
- {item.payload.reason !== undefined &&
{item.payload.reason}
}
- >
- ) : (
- <>
-
等待回答({item.payload.questions.length} 题)
-
- >
- )}
-
请在原客户端处理(web 端作答后续里程碑提供)
+
等待审批:{item.payload.toolName}
+ {item.payload.reason !== undefined &&
{item.payload.reason}
}
+
请在原客户端处理(web 端审批后续里程碑提供)
)
})
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
index 018529961f..c18bbefb01 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -62,12 +62,6 @@
color: var(--dsw-alias-label-tertiary);
}
-/* The others-variant sparkle glyph is one gray step darker than the icon
- family in the source design. */
-.root[data-variant='others'] .leading {
- color: var(--dsw-alias-label-secondary);
-}
-
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
shared product accent and tool-owned action title. */
.root[data-tool^='cordis_'] .leading,
@@ -87,10 +81,6 @@ button.leading {
cursor: pointer;
}
-.chevron {
- color: var(--dsw-alias-label-secondary);
-}
-
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
into a down chevron before the row is opened. The chevron overlays the
icon cell absolutely so both can stay mounted for the opacity transition. */
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index 5c5d059292..6abca0d739 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -7,7 +7,6 @@
// expandable content, retiring the details-panel handoff where feasible.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
-import clsx from 'clsx'
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -74,12 +73,12 @@ export function ToolRow({
? (
<>
{icon}
-
+
>
)
: icon
const leading = open
- ?
+ ?
: leadingFor(state, collapsedIcon)
return (
diff --git a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
new file mode 100644
index 0000000000..3ba94cc438
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
@@ -0,0 +1,94 @@
+// ask_user_question toolview: question-flavored summary row replacing the
+// generic "Tool call" card, registered into the keyed
+// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
+// (chrome, running sweep, leading expansion) and swaps in the interaction
+// outcome — `waiting` while pending, answered-count once settled, `cancelled`
+// when the user dismissed the whole set — because the questions themselves
+// render in the composer takeover.
+
+import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { Context } from 'cordis'
+import type { ToolRowProps } from '../contract/slots.ts'
+import { toolRowModel } from '../contract/tool-call-model.ts'
+import { ToolRow } from '../chat/ToolRow.tsx'
+
+/** One parsed answer entry, shape-checked (result JSON crosses the wire). */
+interface AnswerEntry { selected?: unknown; custom?: unknown }
+
+function isAnswer(value: unknown): value is AnswerEntry {
+ return typeof value === 'object' && value !== null
+}
+
+/** `${answered}/${total} answered` off the result JSON (a skipped question has
+ * empty `selected` and no `custom`); null on unexpected shape (generic fallback). */
+function answeredSummary(text: string): string | null {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(text)
+ } catch {
+ return null
+ }
+ if (typeof parsed !== 'object' || parsed === null) return null
+ const answers = (parsed as { answers?: unknown }).answers
+ if (!Array.isArray(answers) || !answers.every(isAnswer)) return null
+ const answered = answers.filter(a =>
+ (Array.isArray(a.selected) && a.selected.length > 0)
+ || (typeof a.custom === 'string' && a.custom !== '')).length
+ return `${answered}/${answers.length} answered`
+}
+
+/** One-line question-interaction row (row click opens details; leading toggle
+ * expands the raw args). */
+export function AskQuestionRow({ toolName, block, openDetails }: ToolRowProps) {
+ const model = toolRowModel(toolName, block)
+ // Composer verdicts settle the call as specific UserInteractionErrors
+ // (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
+ // dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the
+ // question was pending. Both name their verdict instead of the generic
+ // failed shape, and the abort keeps the shared stopped (amber) semantics of
+ // any other interrupted tool call.
+ const code = 'kind' in block ? block.error?.code : undefined
+ let summary = model.summary
+ let state = model.state
+ if (code === 'ASK_CANCELLED') {
+ summary = 'cancelled'
+ } else if (code === 'ASK_ABORTED') {
+ summary = 'interrupted'
+ state = 'stopped'
+ } else if (model.state === 'running') {
+ summary = 'waiting'
+ } else if ('kind' in block && model.state === 'ok') {
+ const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('')
+ summary = answeredSummary(text) ?? model.summary
+ }
+ return (
+
}
+ title="Ask question"
+ summary={summary}
+ body={model.body}
+ state={state}
+ onOpenDetails={openDetails}
+ />
+ )
+}
+
+/**
+ * The ask-question row as a plain registrant plugin, riding the same
+ * load-order seam as todo-toolview: `inject: ['conversation']` guarantees the
+ * chat entry (and with it the 'conversation.chat.toolview' declaration) is on
+ * the ledger.
+ */
+export const askQuestionToolview = {
+ name: 'ask-question-toolview',
+ inject: ['slots', 'conversation'],
+ /**
+ * Register the ask-question row into the chat view's keyed toolview hole.
+ * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
+ */
+ apply(ctx: Context): void {
+ ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
+ },
+}
diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
deleted file mode 100644
index 1a1b142b3a..0000000000
--- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
+++ /dev/null
@@ -1,58 +0,0 @@
-/* todo_write plan-update row: ToolRow chrome (figma 780:53675) —
- [16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
-
-.row {
- display: flex;
- align-items: center;
- height: 24px;
- min-width: 0;
- cursor: pointer;
- border-radius: 6px;
-}
-
-.leading {
- flex: none;
- width: 16px;
- height: 16px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- margin-right: 6px;
- color: var(--dsw-alias-label-tertiary);
-}
-
-.title {
- flex: none;
- font-size: 14px;
- line-height: 24px;
- font-weight: 500; /* figma wt510, rendered 500 */
- color: var(--dsw-alias-label-primary-dimmed);
-}
-
-.sep {
- flex: none;
- width: 2px;
- height: 2px;
- border-radius: 1px;
- margin: 0 8px;
- background: var(--dsw-alias-label-caption);
-}
-
-.summary {
- flex: 1 1 auto;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 14px;
- line-height: 24px;
- color: var(--dsw-alias-label-tertiary);
-}
-
-.err {
- flex: none;
- margin-left: 8px;
- color: var(--dsw-alias-state-error-primary);
- font-size: 11px;
- line-height: 16px;
-}
diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
index a47322b614..2d72cfc700 100644
--- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
+++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
@@ -1,16 +1,16 @@
// todo_write toolview: plan-flavored summary row replacing the generic
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
// hole like the bash sample (a product registration, not a sample). The row
-// summarizes the written list (counts + active item) from the call args; the
+// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a
+// summary of the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
-// row stays one line. Chrome matches ToolRow (figma 780:53675).
+// row stays one line.
-import type { KeyboardEvent } from 'react'
+import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
-import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
-import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
-import css from './todo-row.module.css'
+import { toolRowModel } from '../contract/tool-call-model.ts'
+import { ToolRow } from '../chat/ToolRow.tsx'
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
@@ -40,48 +40,25 @@ function summarize(argsRaw: string): string | null {
: head
}
-/** Leading-slot state substitution matches ToolRow / bash: icon yields to the
- * state semantic while running or failed; ok keeps the checklist glyph. */
-function leadingFor(state: ToolRowState) {
- switch (state) {
- case 'running': return
- case 'error': return
- case 'stopped': return
- default: return
- }
-}
-
-/** One-line plan update row (click opens the raw args in details). Non-ok
- * execution states keep the generic row's dot semantics — a cancelled call
- * wrote no todo/write, so it must not read as a completed update. */
+/** One-line plan update row (row click opens details; leading toggle expands
+ * the raw args). Non-ok execution states keep the shared row's dot semantics
+ * — a cancelled call wrote no todo/write, so it must not read as a completed
+ * update. */
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
- // Button semantics, not a
: the row carries inline spans a button
- // would flatten, and ToolRow takes the same role/tabIndex/Enter-Space route.
- const openFromKeyboard = (event: KeyboardEvent) => {
- if (event.key !== 'Enter' && event.key !== ' ') return
- event.preventDefault()
- openDetails()
- }
return (
-
- {leadingFor(model.state)}
- 更新任务清单
-
- {summary}
- {model.state === 'error' && failed }
- {model.state === 'stopped' && 已中断 }
-
+ }
+ title="更新任务清单"
+ summary={summary}
+ body={model.body}
+ state={model.state}
+ onOpenDetails={openDetails}
+ />
)
}
diff --git a/packages/client/ui-conversation/tests/ask-question-row.spec.tsx b/packages/client/ui-conversation/tests/ask-question-row.spec.tsx
new file mode 100644
index 0000000000..a1c9412d33
--- /dev/null
+++ b/packages/client/ui-conversation/tests/ask-question-row.spec.tsx
@@ -0,0 +1,130 @@
+// @vitest-environment jsdom
+/**
+ * ask_user_question toolview acceptance: `waiting` summary while running,
+ * answered-count from the result JSON once settled (skipped answers
+ * excluded), the cancelled/interrupted verdicts off ASK_CANCELLED and
+ * ASK_ABORTED, shared ToolRow state
+ * semantics for interrupted/failed calls, and generic fallbacks on
+ * malformed results.
+ */
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+// Export discipline: packages/client/AGENTS.md.
+import { AskQuestionRow, askQuestionToolview } from '../src/client/toolviews/ask-question-row.tsx'
+
+afterEach(cleanup)
+
+const ARGS = JSON.stringify({ questions: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] })
+
+const resultNode = (argsRaw: string, resultText: string | null, over?: Partial): ToolResultNode => ({
+ kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
+ call: { name: 'ask_user_question', argsRaw },
+ content: resultText === null ? [] : [{ type: 'text', text: resultText }],
+ isError: false, callView: null, resultView: null, ...over,
+})
+
+const runningCall = (argsRaw: string) =>
+ ({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
+
+function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
+ return {
+ callId: 'c1', toolName: 'ask_user_question', block,
+ openDetails,
+ sessionId: 's1',
+ useSessions: () => undefined,
+ } as unknown as ToolRowProps
+}
+
+const answers = (entries: unknown[]): string => JSON.stringify({ answers: entries })
+
+describe('AskQuestionRow', () => {
+ it('running call reads waiting (args-independent: the composer takeover shows the questions)', () => {
+ const view = render( )
+ expect(screen.getByText('Ask question')).toBeTruthy()
+ expect(screen.getByText('waiting')).toBeTruthy()
+ expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
+ })
+
+ it('settled result counts answered entries (selected choices or custom text)', () => {
+ render( )
+ expect(screen.getByText('3/3 answered')).toBeTruthy()
+ })
+
+ it('skipped questions (no selection, no custom) stay out of the answered count', () => {
+ const view = render( )
+ expect(screen.getByText('1/3 answered')).toBeTruthy()
+ expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
+ })
+
+ it.each([
+ { label: 'non-JSON result text', text: 'oops' },
+ { label: 'non-object result root', text: '"str"' },
+ { label: 'null result root', text: 'null' },
+ { label: 'missing answers array', text: '{"other":1}' },
+ { label: 'null answer entries', text: '{"answers":[null]}' },
+ { label: 'empty result content', text: null },
+ ])('settled result falls back to the generic summary on $label', ({ text }) => {
+ render( )
+ expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
+ })
+
+ it('user cancellation names the verdict instead of the generic failed shape', () => {
+ // ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
+ const view = render( )
+ expect(screen.getByText('cancelled')).toBeTruthy()
+ expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
+ })
+
+ it('a turn abort while pending reads interrupted with stopped semantics', () => {
+ // ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
+ const view = render( )
+ expect(screen.getByText('interrupted')).toBeTruthy()
+ expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
+ })
+
+ it('an interrupted turn reads as stopped, not cancelled', () => {
+ const view = render( )
+ expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
+ expect(screen.queryByText('cancelled')).toBeNull()
+ expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
+ })
+
+ it('other tool errors keep the generic summary with the error state', () => {
+ const view = render( )
+ expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
+ expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
+ })
+
+ it('window-truncated result (call head lost) falls back to the callId summary', () => {
+ render( )
+ expect(screen.getByText('ask_user_question · c1')).toBeTruthy()
+ })
+
+ it('row click opens details', () => {
+ const openDetails = vi.fn()
+ render( )
+ fireEvent.click(screen.getByText('Ask question'))
+ expect(openDetails).toHaveBeenCalledTimes(1)
+ })
+
+ it('askQuestionToolview is a plain registrant riding the conversation load-order seam', () => {
+ expect(askQuestionToolview.name).toBe('ask-question-toolview')
+ expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
+ const register = vi.fn()
+ askQuestionToolview.apply({ slots: { register } } as never)
+ expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
+ })
+})
diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx
index dab04e94c7..314d5db94f 100644
--- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx
@@ -112,13 +112,13 @@ describe('apply wiring', () => {
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
})
- it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
+ it('mounts the bash sample and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
await b.fiber.await()
- // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
+ // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview')
- expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
+ expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write', 'ask_user_question'])
})
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx
index ccac62c74f..8c8b72a1d8 100644
--- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx
+++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx
@@ -1,20 +1,17 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
-// PendingCard question arm, bash sample state dots, the node-half empty
-// apply, and AssistantMarkdown reasoning/unknown block arms.
+// bash sample state dots, the node-half empty apply, and AssistantMarkdown
+// reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
-import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
-import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as nodeApply } from '../src/index.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
-import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
@@ -33,13 +30,6 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
- it('PendingCard renders the question arm with its count', () => {
- const view = render(
- ['payload'], vi.fn())} />,
- )
- expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
- })
-
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
const view = render(
{
expect(screen.getByText('1/1 已完成')).toBeTruthy()
})
- it('keeps the non-ok execution states visible: running dot, interrupted marker', () => {
- // A running call (no result yet) shows the ongoing dot, never the ok badge.
+ it('keeps the non-ok execution states visible through the shared row states', () => {
+ // A running call (no result yet) carries the running state (row sweep).
const args = JSON.stringify({ todos: LIST })
const running = render( )
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
@@ -130,12 +130,11 @@ describe('TodoRow', () => {
// A cancelled call wrote no todo/write: the row must not read as a completed update.
const stopped = render( )
expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
- expect(stopped.getByText('已中断')).toBeTruthy()
})
- it('falls back to the generic summary on malformed args and flags errors', () => {
- render( )
- expect(screen.getByText('failed')).toBeTruthy()
+ it('falls back to the generic summary on malformed args and marks the error state', () => {
+ const view = render( )
+ expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
// Generic others summary: " · ".
expect(screen.getByText('todo_write · not json')).toBeTruthy()
})
@@ -148,19 +147,14 @@ describe('TodoRow', () => {
expect(openDetails).toHaveBeenCalledTimes(1)
})
- it('opens details from the keyboard on Enter and Space, ignoring other keys', () => {
+ it('leading toggle expands the raw args body without opening details', () => {
const openDetails = vi.fn()
render( )
- const row = screen.getByRole('button')
- expect(row.getAttribute('tabindex')).toBe('0')
- fireEvent.keyDown(row, { key: 'Enter' })
- fireEvent.keyDown(row, { key: ' ' })
- expect(openDetails).toHaveBeenCalledTimes(2)
- // Space must not also scroll the flow: the handler claims the event.
- expect(fireEvent.keyDown(row, { key: ' ' })).toBe(false)
- fireEvent.keyDown(row, { key: 'a' })
- fireEvent.keyDown(row, { key: 'ArrowDown' })
- expect(openDetails).toHaveBeenCalledTimes(3)
+ fireEvent.click(screen.getByRole('button', { expanded: false }))
+ expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
+ // The expanded body is the pretty-printed args, not the tool output.
+ expect(screen.getByText(/搭骨架/)).toBeTruthy()
+ expect(openDetails).not.toHaveBeenCalled()
})
it.each([
diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx
index 23b3bcb835..daa1e0694f 100644
--- a/packages/client/ui-primitives/src/icons/index.tsx
+++ b/packages/client/ui-primitives/src/icons/index.tsx
@@ -123,6 +123,16 @@ export const IconCheckOutline16 = ({ size = 16, className }: IconProps) => (
)
+/** ic_ds_check_outline_14 */
+export const IconCheckOutline14 = ({ size = 14, className }: IconProps) => (
+
+
+
+)
+
/** ic_ds_branch_outline_16 */
export const IconBranchOutline16 = ({ size = 16, className }: IconProps) => (
@@ -653,13 +663,13 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
)
-/** ic_checklist_outline_16 (figma extract): two rings + two list bars. */
-export const IconChecklistOutline16 = ({ size = 16, className }: IconProps) => (
-
-
-
-
-
+/** ic_checklist_outline_14 (figma extract): two rings + two list bars. */
+export const IconChecklistOutline14 = ({ size = 14, className }: IconProps) => (
+
+
+
+
+
)
@@ -680,3 +690,18 @@ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
/>
)
+
+/** ic_ds_question_outline_14 (figma extract): ring + question glyph. */
+export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => (
+
+
+
+
+
+)
diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx
index c06a2e52fc..6f2c98af2e 100644
--- a/packages/client/ui-primitives/tests/icons.spec.tsx
+++ b/packages/client/ui-primitives/tests/icons.spec.tsx
@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
- it('exports the full P-I set (43 deepsuite + 13 figma extracts)', () => {
- expect(iconNames.length).toBe(56)
+ it('exports the full P-I set (43 deepsuite + 15 figma extracts)', () => {
+ expect(iconNames.length).toBe(58)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml
index 476bd3f0b6..adf2a9311b 100644
--- a/packages/client/ui-question/README.i18n.yaml
+++ b/packages/client/ui-question/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: 28132d1d0643f5e8f658ab77de9017467da2d172
-README.zh.md: c70e77fc90eb5b226ceabd8a1e7cc7ca6c011c40
+# pnpm run verify-translation-pairing --write packages/client/ui-question/README.md
+README.md: 4611fc340426098bc1aa12f17e580fd829f0c702
+README.zh.md: 50e69a25175a948c6c026a35d9a549397f820be6
diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md
index 28132d1d06..4611fc3404 100644
--- a/packages/client/ui-question/README.md
+++ b/packages/client/ui-question/README.md
@@ -8,6 +8,8 @@ The component renders one question at a time with progress navigation, single- a
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
+Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.
+
## Model Experience
Indirectly, through `dsh-tool-ask-user`; that package owns the model-visible tool schema and structured result.
diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md
index c70e77fc90..50e69a2517 100644
--- a/packages/client/ui-question/README.zh.md
+++ b/packages/client/ui-question/README.zh.md
@@ -8,6 +8,8 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。
+编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该配置项,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
+
## 模型体验
通过 `dsh-tool-ask-user` 间接影响;该包拥有模型可见的工具 schema 和结构化结果。
diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json
index e40deeb248..b35c354903 100644
--- a/packages/client/ui-question/package.json
+++ b/packages/client/ui-question/package.json
@@ -24,7 +24,8 @@
},
"dshClient": {
"inject": [
- "@deepseek-ai/dsh-client-ui-conversation"
+ "@deepseek-ai/dsh-client-ui-conversation",
+ "@deepseek-ai/dsh-client-locale"
],
"platform": "web"
},
@@ -35,6 +36,7 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
+ "@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
diff --git a/packages/client/ui-question/src/client/QuestionComposer.module.css b/packages/client/ui-question/src/client/QuestionComposer.module.css
index 19306bd552..0794efc076 100644
--- a/packages/client/ui-question/src/client/QuestionComposer.module.css
+++ b/packages/client/ui-question/src/client/QuestionComposer.module.css
@@ -1,24 +1,29 @@
+/* The takeover seats where the input card sits, so the frame mirrors the
+ InputBar geometry (side pad 32, card cap 800) to keep both edges flush. */
.frame {
display: flex;
justify-content: center;
- padding: 6px 24px 10px;
+ padding: 6px 32px 10px;
}
+/* Figma Input 973:36348 body over the 1019:36938 header: no banner strip —
+ the card keeps zero padding and sections carry their own insets. */
.card {
display: flex;
flex-direction: column;
width: 100%;
- max-width: 720px;
+ max-width: 800px;
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the option list
so header and footer actions stay reachable on long batches. */
max-height: min(60vh, 520px);
- padding: 14px 16px 12px;
+ padding: 0 0 10px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
- border-radius: 18px;
+ border-radius: 20px;
background: var(--dsw-specific-input-major);
- box-shadow: var(--dsw-shadow-lv1-blur);
+ box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
+ overflow: hidden;
}
.card,
@@ -26,44 +31,34 @@
box-sizing: border-box;
}
+/* Figma 1019:36938 header, user-tuned: heading block left, close right; the
+ pager sits in the footer to balance the card. */
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
- margin-bottom: 8px;
+ padding: 20px 16px 0 24px;
}
.headingBlock {
min-width: 0;
- padding: 1px 2px;
}
.eyebrow {
- margin-bottom: 2px;
+ /* Eyebrow-to-title gap widened from the figma 2px (user-tuned). */
+ margin-bottom: 5px;
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 16px;
}
.title {
- display: flex;
- align-items: baseline;
- flex-wrap: wrap;
- gap: 6px;
margin: 0;
font-size: 16px;
line-height: 22px;
- font-weight: 600;
-}
-
-.multiSelectHint {
- color: var(--dsw-alias-label-tertiary);
- font-size: 14px;
- line-height: 20px;
- font-weight: 400;
- white-space: nowrap;
+ font-weight: 500;
}
.detail {
@@ -74,20 +69,29 @@
font-weight: 400;
}
-.headerActions,
.footerActions {
display: flex;
align-items: center;
- gap: 4px;
+ gap: 12px;
+ flex-shrink: 0;
+}
+
+.pager {
+ display: flex;
+ align-items: center;
+ gap: 6px;
flex-shrink: 0;
}
.progress {
- padding: 0 6px;
- color: var(--dsw-alias-label-tertiary);
- font-size: 12px;
+ padding: 0 4px;
+ color: var(--dsw-alias-label-secondary);
+ font-size: 14px;
line-height: 24px;
+ font-weight: 500;
white-space: nowrap;
+ /* Narrow the plain spaces around the slash without touching glyph tracking. */
+ word-spacing: -2px;
}
.iconButton {
@@ -116,7 +120,9 @@
.options {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 1px;
+ margin: 8px 0 0;
+ padding: 4px 12px;
/* The scrollable region of the capped card (ChatView list pattern). */
min-height: 0;
overflow-y: auto;
@@ -125,15 +131,15 @@
.option {
display: flex;
align-items: center;
- gap: 10px;
+ gap: 8px;
width: 100%;
- min-height: 42px;
+ min-height: 40px;
/* Rows are the scroll content, never the slack absorber: a shrinkable row
collapses to min-height while its wrapped copy keeps the taller
intrinsic height, and centered content then paints outside the row box —
over the title and the next row. Overflow belongs to .options. */
flex-shrink: 0;
- padding: 5px 8px;
+ padding: 6px 12px 6px 8px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
@@ -152,25 +158,63 @@
border-color: var(--dsw-alias-border-l2);
}
-.option:disabled,
-.customTrigger:disabled {
+.option:disabled {
cursor: default;
}
+/* Leading indicator (figma 20×20, radius 6): single-select shows the option
+ number, multi-select swaps in a checkbox; the custom-answer row follows —
+ its checkbox mirrors the typed draft (styling only, exclusivity holds). */
.number {
display: grid;
place-items: center;
- flex: 0 0 28px;
- width: 28px;
- height: 28px;
- border: 1px solid var(--dsw-alias-border-l2);
- border-radius: 999px;
- background: var(--dsw-alias-bg-module-platform);
- color: var(--dsw-alias-label-tertiary);
+ flex: 0 0 20px;
+ width: 20px;
+ height: 20px;
+ border-radius: 6px;
+ background: var(--dsw-alias-bg-overlay);
+ color: var(--dsw-alias-label-secondary);
font-size: 12px;
+ font-weight: 500;
line-height: 18px;
}
+/* Multi-select box (figma 1055:41594, user-tuned down to 14×14): a radius-4
+ box centered in the 20px indicator seat; the box itself is the ::before
+ layer so the check icon stacks over it in the same grid cell. */
+.checkbox {
+ display: grid;
+ place-items: center;
+ flex: 0 0 20px;
+ width: 20px;
+ height: 20px;
+}
+
+.checkbox::before {
+ content: '';
+ grid-area: 1 / 1;
+ width: 14px;
+ height: 14px;
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 4px;
+ transition: background-color 120ms ease, border-color 120ms ease;
+}
+
+.checkbox > svg {
+ grid-area: 1 / 1;
+}
+
+/* Checked: label-primary fill with a primary-foreground check — the pair
+ inverts with the theme (dark fill in light mode, light fill in dark mode). */
+.checkboxChecked {
+ color: var(--dsw-alias-label-primary-foreground);
+}
+
+.checkboxChecked::before {
+ border-color: var(--dsw-alias-label-primary);
+ background: var(--dsw-alias-label-primary);
+}
+
.optionCopy {
min-width: 0;
flex: 1;
@@ -185,103 +229,101 @@
.optionLabel {
font-size: 14px;
- line-height: 20px;
- font-weight: 600;
+ line-height: 24px;
+ font-weight: 500;
}
.badge {
- padding: 0 6px;
- border-radius: 999px;
- background: var(--dsw-alias-bg-module-platform);
- color: var(--dsw-alias-label-secondary);
+ padding: 0 4px;
+ border-radius: 6px;
+ background: var(--dsw-specific-sidebar-nav-item-active-accent);
+ color: var(--dsw-alias-button-info-fill);
font-size: 11px;
line-height: 18px;
+ font-weight: 600;
}
.description {
color: var(--dsw-alias-label-tertiary);
- font-size: 13px;
- line-height: 20px;
+ font-size: 14px;
+ line-height: 24px;
font-weight: 400;
}
-.choiceIcon {
- display: grid;
- place-items: center;
- width: 20px;
- color: var(--dsw-alias-label-tertiary);
-}
-
-.custom {
- /* Same reason as .option: the custom block is scroll content, and shrinking
- it pushes its trigger row (and the open textarea) past the footer. */
- flex-shrink: 0;
- border: 1px solid transparent;
- border-radius: 12px;
-}
-
-.customOpen {
- border-color: var(--dsw-alias-border-l2);
- background: var(--dsw-alias-bg-module-platform);
-}
-
-.customOptionless {
- border: none;
- background: transparent;
-}
-
-.customTrigger {
+/* Custom answer row (figma 973:36427): an option-shaped row whose copy is an
+ inline text input; focus or a typed draft lifts it to the selected look. */
+.customRow {
display: flex;
align-items: center;
- gap: 10px;
+ gap: 8px;
width: 100%;
- min-height: 42px;
- padding: 5px 8px;
- border: none;
- background: transparent;
- color: var(--dsw-alias-label-tertiary);
- font-size: 14px;
- line-height: 20px;
- text-align: left;
- cursor: pointer;
+ min-height: 40px;
+ /* Same reason as .option: the custom row is scroll content, and shrinking
+ it pushes the inline input past the footer. */
+ flex-shrink: 0;
+ padding: 6px 12px 6px 8px;
+ border: 1px solid transparent;
+ border-radius: 12px;
+ transition: background-color 120ms ease, border-color 120ms ease;
}
-.customTrigger:hover:not(:disabled) {
- color: var(--dsw-alias-label-primary);
+.customRow:hover,
+.customRow:focus-within,
+.customRowActive {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.customRow:focus-within,
+.customRowActive {
+ border-color: var(--dsw-alias-border-l2);
}
.customInput {
- display: block;
- width: calc(100% - 20px);
- min-height: 54px;
- max-height: 140px;
- margin: 0 10px 10px;
- padding: 7px 10px;
- resize: vertical;
- border: 1px solid var(--dsw-alias-border-l2);
- border-radius: 10px;
+ flex: 1;
+ min-width: 0;
+ padding: 0;
+ border: none;
outline: none;
- background: var(--dsw-specific-input-major);
+ background: transparent;
color: var(--dsw-alias-label-primary);
caret-color: var(--dsw-alias-state-business-primary);
font: inherit;
- font-size: 13px;
- line-height: 20px;
-}
-
-.customInput:focus {
- border-color: var(--dsw-alias-state-business-primary);
+ font-size: 14px;
+ line-height: 24px;
}
.customInput::placeholder {
color: var(--dsw-alias-label-caption);
}
-.customOptionless .customInput {
- width: 100%;
- min-height: 58px;
- margin: 0;
+/* Optionless question: the free-form answer is the whole body. The 12px side
+ margins add to the .options 12px padding so both edges align with the
+ title's 24px inset; type matches the option rows, no resize handle. */
+.customTextarea {
+ display: block;
+ min-height: 64px;
+ max-height: 140px;
+ flex-shrink: 0;
+ margin: 0 12px;
+ padding: 8px 12px;
+ resize: none;
+ border: 1px solid var(--dsw-alias-border-l2);
+ border-radius: 10px;
+ outline: none;
background: var(--dsw-alias-bg-module-platform);
+ color: var(--dsw-alias-label-primary);
+ caret-color: var(--dsw-alias-state-business-primary);
+ font: inherit;
+ font-size: 14px;
+ line-height: 24px;
+}
+
+.customTextarea:focus {
+ border-color: var(--dsw-alias-state-business-primary);
+}
+
+.customTextarea::placeholder {
+ color: var(--dsw-alias-label-caption);
}
.footer {
@@ -290,15 +332,17 @@
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
- margin-top: 8px;
- padding: 0 2px;
+ margin-top: 12px;
+ padding: 0 10px 0 18px;
}
.feedback {
+ flex: 1;
min-height: 16px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
+ text-align: right;
}
@media (max-width: 720px) {
@@ -307,21 +351,15 @@
}
.card {
- padding: 12px 10px 10px;
border-radius: 16px;
}
.header {
- display: block;
+ padding: 10px 12px 0 18px;
}
- .headerActions {
- justify-content: flex-end;
- margin-top: 8px;
- }
-
- .headingBlock {
- padding: 0 2px;
+ .options {
+ padding: 4px 8px;
}
.title {
@@ -330,18 +368,15 @@
}
.option,
- .customTrigger {
+ .customRow {
align-items: flex-start;
gap: 8px;
padding: 6px;
}
- .choiceIcon {
- margin-top: 3px;
- }
-
.footer {
align-items: flex-end;
+ padding: 0 10px;
}
.footerActions {
@@ -350,7 +385,8 @@
}
@media (prefers-reduced-motion: reduce) {
- .option {
+ .option,
+ .customRow {
transition: none;
}
}
diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx
index 4380ae8db0..4e87126417 100644
--- a/packages/client/ui-question/src/client/QuestionComposer.tsx
+++ b/packages/client/ui-question/src/client/QuestionComposer.tsx
@@ -1,19 +1,27 @@
import { useMemo, useState, type KeyboardEvent } from 'react'
import clsx from 'clsx'
import {
- Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
+ Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
selected: string[]
custom: string
- customOpen: boolean
skipped: boolean
}
+/**
+ * Displayed feedback: validation feedback is stored as a dictionary key so a
+ * locale flip re-translates it; carrier failures arrive as raw (untranslated)
+ * messages and display verbatim.
+ */
+type Feedback = { key: 'error.incomplete' | 'error.empty' } | { message: string }
+
/**
* Split the conventional recommendation suffix without changing the answer value.
* @param label - Original option label returned if selected.
@@ -26,17 +34,8 @@ export function parseRecommendedLabel(label: string): { label: string; recommend
: { label, recommended: false }
}
-/**
- * Remove a conventional multi-select suffix so the hint can be styled separately.
- * @param title - Question title supplied by the interaction request.
- * @returns Question title without a trailing multi-select marker.
- */
-export function parseQuestionTitle(title: string): string {
- return title.replace(/\s*[((]可多选[))]\s*$/, '')
-}
-
-/** Return whether a textarea key event belongs to an active IME composition. */
-function isComposing(event: KeyboardEvent): boolean {
+/** Return whether a text-field key event belongs to an active IME composition. */
+function isComposing(event: KeyboardEvent): boolean {
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
@@ -52,17 +51,24 @@ export function QuestionComposer(props: QuestionComposerProps) {
// Domain-face mint rides the carrier's stable identity (never minted in a
// select/render dispatch — per-dispatch minting would churn memo identity).
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
- return
+ return
}
-function QuestionFlow({ pending }: { pending: PendingQuestion }) {
+function QuestionFlow({ pending, t, useLocale }: {
+ pending: PendingQuestion
+ t: Translate
+ useLocale: SnapshotSelectorHook
+}) {
+ // Subscription only: t reads the active locale at call time, so the
+ // revision selector exists to re-render this tree on locale flips.
+ useLocale(snapshot => snapshot.revision)
const questions = pending.questions
const [index, setIndex] = useState(0)
- const [drafts, setDrafts] = useState(() => questions.map(question => ({
- selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
+ const [drafts, setDrafts] = useState(() => questions.map(() => ({
+ selected: [], custom: '', skipped: false,
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
- const [error, setError] = useState(null)
+ const [error, setError] = useState(null)
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const question = questions[index]!
@@ -75,7 +81,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
setError(null)
void pending.cancel().catch((cause: unknown) => {
setBusy(null)
- setError(cause instanceof Error ? cause.message : String(cause))
+ setError({ message: cause instanceof Error ? cause.message : String(cause) })
})
}
@@ -91,17 +97,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
? current.selected.filter(item => item !== label)
: [...current.selected, label]
: [label]
- return { selected, custom: '', customOpen: false, skipped: false }
+ return { selected, custom: '', skipped: false }
})
if (question.multiSelect !== true && index < questions.length - 1) {
setIndex(current => current + 1)
}
}
- const openCustom = (): void => {
- updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false }))
- }
-
const answered = (item: DraftAnswer): boolean =>
item.selected.length > 0 || item.custom.trim() !== ''
@@ -111,7 +113,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const missing = values.findIndex(item => !completed(item))
if (missing >= 0) {
setIndex(missing)
- setError('请先完成这道问题。')
+ setError({ key: 'error.incomplete' })
return
}
const answer: QuestionAnswer = {
@@ -130,13 +132,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
setError(null)
void pending.answer(answer).catch((cause: unknown) => {
setBusy(null)
- setError(cause instanceof Error ? cause.message : String(cause))
+ setError({ message: cause instanceof Error ? cause.message : String(cause) })
})
}
const continueFlow = (): void => {
if (!answered(draft)) {
- setError('请选择一个选项或填写自定义答案。')
+ setError({ key: 'error.empty' })
return
}
if (index < questions.length - 1) {
@@ -149,11 +151,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const skipQuestion = (): void => {
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
- ? {
- selected: [], custom: '',
- customOpen: (question.options?.length ?? 0) === 0,
- skipped: true,
- }
+ ? { selected: [], custom: '', skipped: true }
: item)
setDrafts(nextDrafts)
setError(null)
@@ -171,37 +169,17 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
{question.header !== undefined &&
{question.header}
}
- {question.multiSelect === true
- ? parseQuestionTitle(question.question)
- : question.question}
- {question.multiSelect === true && 可多选 }
+ {question.question}
{question.detail !== undefined &&
{question.detail}
}
-
- {index + 1} / {questions.length}
- { setIndex(index - 1); setError(null) }}
- >
-
-
- { setIndex(index + 1); setError(null) }}
- >
-
-
-
-
-
-
+
+
+
@@ -211,7 +189,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
return (
- {optionIndex + 1}
+ {question.multiSelect === true
+ ? (
+
+ {selected && }
+
+ )
+ : {optionIndex + 1} }
{display.label}
- {display.recommended && 推荐 }
+ {display.recommended && {t('option.recommended')} }
{option.description !== undefined && (
{option.description}
)}
-
- {selected ? : }
-
)
})}
-
- {hasOptions && (
-
-
- 其他,请填写自定义答案
-
- )}
- {draft.customOpen && (
+ {hasOptions
+ ? (
+
+ {question.multiSelect === true
+ ? (
+
+ {draft.custom !== '' && }
+
+ )
+ : (
+
+
+
+ )}
+ {
+ const value = event.target.value
+ updateDraft(current => ({
+ ...current, selected: [], custom: value, skipped: false,
+ }))
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && !isComposing(event)) {
+ event.preventDefault()
+ continueFlow()
+ }
+ }}
+ />
+
+ )
+ : (
- {error}
+
+ { setIndex(index - 1); setError(null) }}
+ >
+
+
+ {index + 1} / {questions.length}
+ { setIndex(index + 1); setError(null) }}
+ >
+
+
+
+
+ {error === null ? null : 'key' in error ? t(error.key) : error.message}
+
-
- 跳过本题
+
+ {t('action.skip')}
{busy === 'answer'
- ? '正在提交…'
- : index === questions.length - 1 ? '提交' : '下一题'}
+ ? t('action.submitting')
+ : t(index === questions.length - 1 ? 'action.submit' : 'action.next')}
diff --git a/packages/client/ui-question/src/client/contract/slots.ts b/packages/client/ui-question/src/client/contract/slots.ts
index e3c3e815bf..ec14da1e09 100644
--- a/packages/client/ui-question/src/client/contract/slots.ts
+++ b/packages/client/ui-question/src/client/contract/slots.ts
@@ -6,12 +6,13 @@
* cancelled error encoding, receipt checks — lives HERE, with the package
* that consumes it.
*/
-import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { HostObservable, InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
// entry) into every program that sees this contract, so PropsRuntime resolves.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { QuestionResponsePayload } from '@deepseek-ai/dsh-client-connection/client'
+import type { LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
/** The pending question carrier the owner dispatches into the composer slot. */
export type QuestionWait = PendingWait<'question'>
@@ -68,10 +69,25 @@ export class PendingQuestion {
}
/**
- * Full component props: the framework runtime share (chain currency +
- * session/global standard kit) plus the chain `matched` share — the entry's
- * selector result, already narrowed to the question carrier. No injected
- * share: the carrier plus the domain face above carry the whole behavior
- * surface.
+ * Registrant-injected share: the `question`-namespace translator plus the
+ * locale snapshot as a hooks-compartment source. `t` reads the active locale
+ * at call time; the bound `useLocale` subscription is what re-renders the
+ * composer when the locale flips.
*/
-export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }
+export interface QuestionComposerInjected {
+ /** Translator bound to the `question` namespace. */
+ t: Translate
+ hooks: {
+ /** Live locale snapshot (bound to the `useLocale` selector hook). */
+ locale: HostObservable
+ }
+}
+
+/**
+ * Full component props: the framework runtime share (chain currency +
+ * session/global standard kit), the injected locale share, and the chain
+ * `matched` share — the entry's selector result, already narrowed to the
+ * question carrier. Data and verbs ride the carrier plus the domain face.
+ */
+export type QuestionComposerProps =
+ PropsRuntime<'conversation.composer'> & InjectFace & { matched: QuestionWait }
diff --git a/packages/client/ui-question/src/client/index.ts b/packages/client/ui-question/src/client/index.ts
index 328fa6c6ce..a7703965f7 100644
--- a/packages/client/ui-question/src/client/index.ts
+++ b/packages/client/ui-question/src/client/index.ts
@@ -1,18 +1,23 @@
/**
* Web question plugin, browser half: QuestionComposer registered as a
- * selector-routed entry of the conversation-declared composer chain. Pure
- * consumer — the selector narrows the owner's currency to the question
- * carrier (matched prop), and the whole behavior surface rides the carrier
- * (domain encoding in contract/slots.ts PendingQuestion); no inject face, no
- * service dependency beyond slots. Export discipline: packages/client/AGENTS.md.
+ * selector-routed entry of the conversation-declared composer chain. The
+ * selector narrows the owner's currency to the question carrier (matched
+ * prop); answer/cancel behavior rides the carrier (domain encoding in
+ * contract/slots.ts PendingQuestion); the inject face carries only the
+ * locale share (bound translator + snapshot source). Export discipline:
+ * packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import type { QuestionWait } from './contract/slots.ts'
+// Type-only: pulls the locale plugin's Context merge (ctx.locale).
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type { QuestionComposerInjected, QuestionWait } from './contract/slots.ts'
+import { en, QUESTION_NS, zh } from './locales.ts'
import { QuestionComposer } from './QuestionComposer.tsx'
export { PendingQuestion } from './contract/slots.ts'
-export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
+export type { QuestionAnswer, QuestionComposerInjected, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
+export { QUESTION_NS } from './locales.ts'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
@@ -20,7 +25,7 @@ export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './cont
* declared by ui-conversation's apply, and register() into an undeclared
* slot throws — service waiting orders this apply after the declaring one.
*/
-export const inject = ['slots', 'conversation']
+export const inject = ['slots', 'conversation', 'locale']
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
@@ -28,14 +33,35 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu
}
/**
- * Client plugin body: register the question composer into the composer chain.
- * Zero business face — data and verbs both live on the matched carrier.
+ * Client plugin body: register the composer's bilingual copy and the question
+ * composer itself into the composer chain. The inject face hands the entry
+ * its namespace-bound translator plus the locale snapshot source; data and
+ * verbs live on the matched carrier.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
- const slots = ctx.slots
+ ctx.effect(() => {
+ const disposers = [
+ ctx.locale.register(QUESTION_NS, 'zh', zh),
+ ctx.locale.register(QUESTION_NS, 'en', en),
+ ]
+ return () => { for (const dispose of disposers) dispose() }
+ }, 'ui-question: composer dictionaries')
+
+ const injected = (): QuestionComposerInjected => ({
+ t: ctx.locale.bind(QUESTION_NS),
+ hooks: {
+ locale: {
+ getSnapshot: () => ctx.locale.getLocale(),
+ subscribe: fn => ctx.on('locale/change', fn),
+ },
+ },
+ })
ctx.effect(
- () => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer),
+ () => ctx.slots.register(
+ { name: 'conversation.composer', select: selectQuestion, inject: injected },
+ QuestionComposer,
+ ),
'ui-question: composer chain registration',
)
}
diff --git a/packages/client/ui-question/src/client/locales.ts b/packages/client/ui-question/src/client/locales.ts
new file mode 100644
index 0000000000..d16a7b6fe3
--- /dev/null
+++ b/packages/client/ui-question/src/client/locales.ts
@@ -0,0 +1,39 @@
+/**
+ * Bilingual copy of the question composer, registered under the `question`
+ * namespace. Question/option text itself arrives from the model verbatim —
+ * these dictionaries cover only the chrome around it.
+ */
+import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client'
+
+/** Namespace owning the question-composer copy. */
+export const QUESTION_NS = 'question'
+
+/** Simplified Chinese dictionary (the fallback locale). */
+export const zh: LocaleDict = {
+ 'dismiss': '放弃整组问题',
+ 'pager.prev': '上一题',
+ 'pager.next': '下一题',
+ 'option.recommended': '推荐',
+ 'custom.placeholder': '输入你的答案',
+ 'error.incomplete': '请先完成这道问题。',
+ 'error.empty': '请选择一个选项或填写自定义答案。',
+ 'action.skip': '跳过本题',
+ 'action.next': '下一题',
+ 'action.submit': '提交',
+ 'action.submitting': '正在提交…',
+}
+
+/** English dictionary. */
+export const en: LocaleDict = {
+ 'dismiss': 'Dismiss all questions',
+ 'pager.prev': 'Previous question',
+ 'pager.next': 'Next question',
+ 'option.recommended': 'Recommended',
+ 'custom.placeholder': 'Type your answer',
+ 'error.incomplete': 'Please finish this question first.',
+ 'error.empty': 'Choose an option or type a custom answer.',
+ 'action.skip': 'Skip this question',
+ 'action.next': 'Next',
+ 'action.submit': 'Submit',
+ 'action.submitting': 'Submitting…',
+}
diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.spec.ts
index 832da15318..3f572f26f1 100644
--- a/packages/client/ui-question/tests/browser-plugin.spec.ts
+++ b/packages/client/ui-question/tests/browser-plugin.spec.ts
@@ -1,16 +1,19 @@
/**
- * apply wiring on a real cordis Context + SlotsService: QuestionComposer
- * registered as the `question` entry of the conversation-declared composer
- * slot with ZERO business face (data and verbs ride the dispatched carrier),
- * load-order fail-loud, and fiber-teardown unregistration. Component and
- * domain-face behavior is covered props-direct in question-composer.spec.tsx;
- * no renderer machinery here.
+ * apply wiring on a real cordis Context + SlotsService + LocaleService:
+ * QuestionComposer registered as the `question` entry of the
+ * conversation-declared composer slot, bilingual dictionaries registered
+ * under the `question` namespace, the locale share handed through the inject
+ * face, load-order fail-loud, and fiber-teardown unregistration. Component
+ * and domain-face behavior is covered props-direct in
+ * question-composer.spec.tsx; no renderer machinery here.
*/
import { Context } from 'cordis'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
+import type { QuestionComposerInjected } from '../src/client/contract/slots.ts'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
-import { apply, inject } from '../src/client/index.ts'
+import { apply, inject, QUESTION_NS } from '../src/client/index.ts'
async function bench() {
const ctx = new Context()
@@ -24,12 +27,14 @@ async function bench() {
// 'conversation' inject is an ordering edge (the declaring plugin provides
// it after declaring the chain); the bench declares the chain itself.
ctx.provide('conversation', {})
- return { ctx, slots }
+ const locale = new LocaleService(ctx)
+ ctx.provide('locale', locale)
+ return { ctx, slots, locale }
}
describe('apply', () => {
it('declares the services it binds', () => {
- expect(inject).toEqual(['slots', 'conversation'])
+ expect(inject).toEqual(['slots', 'conversation', 'locale'])
})
it('fails loud when no live entry has declared the composer slot', async () => {
@@ -38,31 +43,46 @@ describe('apply', () => {
// Satisfy the ordering inject without declaring the chain: apply must
// then hit the undeclared-slot throw, not sit waiting on the service.
ctx.provide('conversation', {})
+ ctx.provide('locale', new LocaleService(ctx))
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.composer" is not declared/)
})
- it('registers the question entry: routing selector, no inject face', async () => {
- const { ctx, slots } = await bench()
+ it('registers the question entry: routing selector plus the locale share face', async () => {
+ const { ctx, slots, locale } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const entry = slots.entries('conversation.composer')[0]!
expect(entry.component).toBe(QuestionComposer)
- // The whole behavior surface rides the matched carrier: no business face.
- expect(entry.inject).toBeUndefined()
// The selector narrows the chain currency: question wait in → that wait; none → null.
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
const question = { kind: 'question' }
expect(select({ interactions: [{ kind: 'approval' }, question] })).toBe(question)
expect(select({ interactions: [{ kind: 'approval' }] })).toBeNull()
expect(select({ interactions: [] })).toBeNull()
+ // The inject face carries the namespace-bound translator and the live
+ // locale snapshot source (subscription rides locale/change).
+ const face = (entry.inject as unknown as () => QuestionComposerInjected)()
+ expect(face.t('action.submit')).toBe('提交')
+ expect(face.hooks.locale.getSnapshot()).toBe(locale.getLocale())
+ const changed = vi.fn()
+ const off = face.hooks.locale.subscribe(changed)
+ locale.setLocale('en')
+ expect(changed).toHaveBeenCalledTimes(1)
+ expect(face.t('action.submit')).toBe('Submit')
+ off()
+ locale.setLocale('zh')
+ expect(changed).toHaveBeenCalledTimes(1)
})
- it('teardown unregisters the slot entry', async () => {
- const { ctx, slots } = await bench()
+ it('teardown unregisters the slot entry and the dictionaries', async () => {
+ const { ctx, slots, locale } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('conversation.composer')).toHaveLength(1)
+ expect(locale.bind(QUESTION_NS)('action.submit')).toBe('提交')
await fiber.dispose()
expect(slots.entries('conversation.composer')).toHaveLength(0)
+ // Unregistered namespace: the lookup chain bottoms out at the key itself.
+ expect(locale.bind(QUESTION_NS)('action.submit')).toBe('action.submit')
})
})
diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx
index dd130d80e2..b24486e208 100644
--- a/packages/client/ui-question/tests/question-composer.spec.tsx
+++ b/packages/client/ui-question/tests/question-composer.spec.tsx
@@ -8,20 +8,29 @@ import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
+import type { LocaleDict, LocaleSnapshot, Translate } from '@deepseek-ai/dsh-client-locale/client'
import { PendingQuestion } from '../src/client/contract/slots.ts'
-import {
- QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
-} from '../src/client/QuestionComposer.tsx'
+import { en, zh } from '../src/client/locales.ts'
+import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
-/** Framework standard-kit stubs: the composer consumes none of them, the
- * composed props type mandates their delivery (framework hooks are plain
- * stubs per the client testing discipline). */
+/** Dictionary-backed translate stub (the lookup chain is the locale package's contract, not re-tested here). */
+const translateOver = (dict: LocaleDict): Translate => key => dict[key] ?? key
+
+/** Locale-share stub: static snapshot, no subscription machinery. */
+const useLocale: SnapshotSelectorHook = select =>
+ select({ active: 'zh', locales: [], revision: 0 })
+
+/** Framework standard-kit stubs: the composer consumes only the locale share;
+ * the composed props type mandates delivery of the rest (framework hooks are
+ * plain stubs per the client testing discipline). */
const kit = {
sessionId: SID,
+ t: translateOver(zh),
+ useLocale,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook,
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook,
@@ -67,6 +76,7 @@ describe('QuestionComposer', () => {
const { carrier, respond } = wait()
render( )
+ expect(screen.getByText('偏好')).toBeTruthy()
expect(screen.getByText('1 / 3')).toBeTruthy()
expect(screen.getByText('推荐')).toBeTruthy()
expect(screen.getByText('工程落地型')).toBeTruthy()
@@ -84,9 +94,8 @@ describe('QuestionComposer', () => {
fireEvent.keyDown(custom, { key: 'Enter' })
expect(screen.getByText('3 / 3')).toBeTruthy()
- expect(screen.getByText('选择重要信号')).toBeTruthy()
- expect(screen.getByText('可多选')).toBeTruthy()
- expect(screen.queryByText('(可多选)')).toBeNull()
+ // The model's question text renders verbatim — no marker filtering.
+ expect(screen.getByText('选择重要信号(可多选)')).toBeTruthy()
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
@@ -140,11 +149,10 @@ describe('QuestionComposer', () => {
expect(screen.getByText('3 / 3')).toBeTruthy()
})
- it('opens custom input, reports missing skipped answers, and supports header navigation', () => {
+ it('shows the inline custom input, reports missing answers, and supports pager navigation', () => {
const { carrier, respond } = wait()
render( )
- fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' }))
expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
const emptyCustom = screen.getByPlaceholderText('输入你的答案')
@@ -205,6 +213,16 @@ describe('QuestionComposer', () => {
expect(await screen.findByText('字符串错误')).toBeTruthy()
})
+ it('renders chrome copy through the English dictionary', () => {
+ const respond = vi.fn(() => Promise.resolve({ accepted: true }))
+ const carrier = new PendingWait(
+ 'question', RpcId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond)
+ render( )
+ expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy()
+ expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
+ })
+
it('same-key carrier replacement (baseline replay) keeps drafts', () => {
const first = wait('same-id')
const view = render( )
@@ -260,11 +278,3 @@ describe('parseRecommendedLabel', () => {
expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
})
})
-
-describe('parseQuestionTitle', () => {
- it('removes Chinese and ASCII multi-select suffixes', () => {
- expect(parseQuestionTitle('选择信号(可多选)')).toBe('选择信号')
- expect(parseQuestionTitle('选择信号 (可多选)')).toBe('选择信号')
- expect(parseQuestionTitle('选择信号')).toBe('选择信号')
- })
-})
diff --git a/packages/client/ui-question/tsconfig.json b/packages/client/ui-question/tsconfig.json
index 4c4138b80d..6b5b0acc3a 100644
--- a/packages/client/ui-question/tsconfig.json
+++ b/packages/client/ui-question/tsconfig.json
@@ -14,6 +14,9 @@
{
"path": "../connection"
},
+ {
+ "path": "../locale"
+ },
{
"path": "../runtime"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b9538051cd..5f1d9b3090 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1112,6 +1112,9 @@ importers:
'@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
From 665c21693b927daef27b9e81b581d777909c7628 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Wed, 29 Jul 2026 14:12:27 +0800
Subject: [PATCH 002/144] feat(tools): add persistent bash and str-replace
editor
---
...rsistent-bash-str-replace-editor.i18n.yaml | 6 +
...7-29-persistent-bash-str-replace-editor.md | 33 ++
...9-persistent-bash-str-replace-editor.zh.md | 33 ++
docs/config-catalog.md | 57 ++-
docs/cordis-catalog/services.md | 2 +-
docs/event-producer-consumer.md | 2 +-
docs/module-graph.md | 12 +
docs/tool-catalog.md | 95 +++++
packages/core/system-prompt/README.i18n.yaml | 6 +-
packages/core/system-prompt/README.md | 5 +-
packages/core/system-prompt/README.zh.md | 5 +-
packages/core/system-prompt/src/index.ts | 15 +-
.../system-prompt/tests/system-prompt.spec.ts | 12 +
.../agent-spine-demo/README.i18n.yaml | 4 +-
packages/examples/agent-spine-demo/README.md | 8 +-
.../examples/agent-spine-demo/README.zh.md | 8 +-
.../examples/agent-spine-demo/src/index.ts | 24 +-
.../agent-spine-demo/tests/agent-core.spec.ts | 21 +-
packages/fs/README.i18n.yaml | 6 +-
packages/fs/README.md | 1 +
packages/fs/README.zh.md | 1 +
.../tool-str-replace-editor/README.i18n.yaml | 6 +
packages/fs/tool-str-replace-editor/README.md | 54 +++
.../fs/tool-str-replace-editor/README.zh.md | 54 +++
.../fs/tool-str-replace-editor/package.json | 48 +++
.../fs/tool-str-replace-editor/src/index.ts | 403 ++++++++++++++++++
.../tool-str-replace-editor/src/invariant.ts | 30 ++
.../tests/tools.spec.ts | 313 ++++++++++++++
.../fs/tool-str-replace-editor/tsconfig.json | 14 +
packages/pty/README.i18n.yaml | 6 +-
packages/pty/README.md | 1 +
packages/pty/README.zh.md | 1 +
.../pty/tool-bash-persistent/README.i18n.yaml | 6 +
packages/pty/tool-bash-persistent/README.md | 50 +++
.../pty/tool-bash-persistent/README.zh.md | 50 +++
.../pty/tool-bash-persistent/package.json | 55 +++
.../pty/tool-bash-persistent/src/index.ts | 380 +++++++++++++++++
.../pty/tool-bash-persistent/src/invariant.ts | 30 ++
.../tests/loader-composition.spec.ts | 156 +++++++
.../tool-bash-persistent/tests/tools.spec.ts | 385 +++++++++++++++++
.../pty/tool-bash-persistent/tsconfig.json | 16 +
patches/node-pty@1.1.0.patch | 60 +++
pnpm-lock.yaml | 105 ++++-
pnpm-workspace.yaml | 4 +
python/README.i18n.yaml | 6 +-
python/README.md | 2 +-
python/README.zh.md | 2 +-
python/sdk-runtime/README.i18n.yaml | 6 +-
python/sdk-runtime/README.md | 6 +-
python/sdk-runtime/README.zh.md | 6 +-
python/sdk-runtime/hatch_build.py | 16 +-
python/sdk-runtime/package.json | 5 +
.../src/deepseek_harness_runtime/__init__.py | 13 +-
python/sdk/tests/test_release_version.py | 38 ++
scripts/build-exe-for-python-sdk.ts | 84 +++-
scripts/build-python-release.py | 29 +-
scripts/gen-tool-catalog.ts | 28 ++
scripts/smoke-python-runtime.py | 145 ++++++-
tsconfig.host.json | 2 +
59 files changed, 2880 insertions(+), 91 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md
create mode 100644 packages/fs/tool-str-replace-editor/README.i18n.yaml
create mode 100644 packages/fs/tool-str-replace-editor/README.md
create mode 100644 packages/fs/tool-str-replace-editor/README.zh.md
create mode 100644 packages/fs/tool-str-replace-editor/package.json
create mode 100644 packages/fs/tool-str-replace-editor/src/index.ts
create mode 100644 packages/fs/tool-str-replace-editor/src/invariant.ts
create mode 100644 packages/fs/tool-str-replace-editor/tests/tools.spec.ts
create mode 100644 packages/fs/tool-str-replace-editor/tsconfig.json
create mode 100644 packages/pty/tool-bash-persistent/README.i18n.yaml
create mode 100644 packages/pty/tool-bash-persistent/README.md
create mode 100644 packages/pty/tool-bash-persistent/README.zh.md
create mode 100644 packages/pty/tool-bash-persistent/package.json
create mode 100644 packages/pty/tool-bash-persistent/src/index.ts
create mode 100644 packages/pty/tool-bash-persistent/src/invariant.ts
create mode 100644 packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts
create mode 100644 packages/pty/tool-bash-persistent/tests/tools.spec.ts
create mode 100644 packages/pty/tool-bash-persistent/tsconfig.json
create mode 100644 patches/node-pty@1.1.0.patch
diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml
new file mode 100644
index 0000000000..e3b6121e19
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
+2026-07-29-persistent-bash-str-replace-editor.md: 286a53c1c686cc515b65119ed4b1a01a57b0614b
+2026-07-29-persistent-bash-str-replace-editor.zh.md: d2417708c8a1334e9f8930481f4218cbefc5a87b
diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
new file mode 100644
index 0000000000..286a53c1c6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
@@ -0,0 +1,33 @@
+# Agent Note: Persistent Bash and string-replacement editor tools
+
+Status: implemented
+
+English | [中文](2026-07-29-persistent-bash-str-replace-editor.zh.md)
+
+## Problem
+
+Some deployments need a one-call Bash schema whose shell state survives across model turns, while others need a Claude-style `str_replace_editor` independent of their terminal choice. Bundling the two tools or naming them after one benchmark would prevent reuse and blur configuration ownership.
+
+## Decision
+
+`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned.
+
+`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. The public schema and failures use only `old_str`; canonical mode requires absolute paths and expands tabs before mutations. Deployments with an intentional session-cwd contract can disable the absolute-path requirement. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell.
+
+`dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged.
+
+Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present (or when `DSH_NODE_PTY_SPAWN_HELPER` explicitly selects one), preserving upstream lookup in ordinary Node runs; the executable and runtime-wheel builders fail before publication when the helper is absent, mismatched, or not executable.
+
+## Alternatives considered
+
+**One combined compatibility plugin.** Rejected because neither tool requires the other and the combined name would tie reusable capabilities to one benchmark.
+
+**Reuse one-shot Bash.** Rejected because `bash -c` cannot preserve cwd or environment state across calls.
+
+**Expose terminal management tools.** Rejected because open/send/read/close is a different model action space from one persistent `bash` call.
+
+**Modify native read/write/edit.** Rejected because it would distort their general-purpose contracts instead of adding an independently composable editor.
+
+## Consequences
+
+Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation, but the wheel now contains a main executable plus its private native helper rather than one physical file.
diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md
new file mode 100644
index 0000000000..d2417708c8
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md
@@ -0,0 +1,33 @@
+# Agent Note:持久 Bash 与字符串替换编辑器工具
+
+状态:已实现
+
+[English](2026-07-29-persistent-bash-str-replace-editor.md) | 中文
+
+## 问题
+
+部分部署需要只调用一次的 Bash schema,同时要求 shell 状态跨模型轮次保留;另一些部署需要与终端选择无关的 Claude 风格 `str_replace_editor`。把两个工具绑在一起或按某个基准命名,会阻碍复用并模糊配置归属。
+
+## 决策
+
+`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。
+
+`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。公开 schema 与错误只使用 `old_str`;规范模式要求绝对路径,并在变更前展开制表符。有明确 session-cwd 契约的部署可以关闭绝对路径要求。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。
+
+`dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。
+
+两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它(也可由 `DSH_NODE_PTY_SPAWN_HELPER` 显式指定),普通 Node 运行仍保留上游查找方式;若 helper 缺失、架构不匹配或不可执行,可执行文件与 runtime wheel 构建会在发布前失败。
+
+## 考虑过的替代方案
+
+**单一组合兼容插件。** 被拒绝,因为两个工具互不依赖,组合命名还会把可复用能力绑定到某个基准。
+
+**复用一次性 Bash。** 被拒绝,因为 `bash -c` 无法跨调用保留 cwd 或环境状态。
+
+**暴露终端管理工具。** 被拒绝,因为 open/send/read/close 与单个持久 `bash` 调用是不同的模型动作空间。
+
+**修改原生 read/write/edit。** 被拒绝,因为这会扭曲其通用契约,而不是增加一个可独立组合的编辑器。
+
+## 后果
+
+Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。runtime wheel 的使用者仍不需要安装 Node,但 wheel 现在包含主可执行文件及其私有原生 helper,而不是单个物理文件。
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 68343a6a19..585ea18b84 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -116,9 +116,9 @@ Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loo
/**
* Bundle config: each field forwarded verbatim to the child that owns it —
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
- * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
- * plugin (the deployment's persona section and the explicit model-facing tool
- * order), the `tools` object to the tool registry (its presentation `mode`),
+ * bridge, simply omits it), `includeHarnessIdentity`, `persona`, and `toolOrder`
+ * to the system-prompt plugin (the fixed opener, deployment persona, and explicit
+ * model-facing tool order), the `tools` object to the tool registry (its presentation `mode`),
* `dshHome` to bash environment and local skill discovery, `sessionTitle` to
* the fallback title service, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
@@ -131,13 +131,16 @@ Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loo
* workspace context instead requires an explicit byte budget or `false` because
* it changes model-visible input. Producer opt-in stays producer-local:
* `toolBash` configures bash only; independently composed producers keep their
- * own config.
+ * own config. Set `toolBash: false` when another plugin owns the model-facing
+ * `bash` name.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
agents?: AgentLoopConfig['agents']
/** Agent-loop concurrency cap; `1` is serial. */
maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls']
+ /** Whether the system prompt includes the fixed Harness identity (default true). */
+ includeHarnessIdentity?: SystemPromptConfig['includeHarnessIdentity']
/** The deployment persona (see dsh-system-prompt's `Config`). */
persona?: SystemPromptConfig['persona']
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
@@ -152,8 +155,8 @@ export interface Config {
workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */
skills?: SkillConfig
- /** Model-facing bash tool config, including this producer's background opt-in. */
- toolBash?: toolBash.Config
+ /** Model-facing bash tool config, or false when another plugin owns `bash`. */
+ toolBash?: toolBash.Config | false
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
toolTasks?: toolTasks.Config | false
/** Global enablement and package-name filters for invariant companions. */
@@ -185,7 +188,7 @@ export interface GoalConfig {
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts)
-Source: [`packages/examples/agent-spine-demo/src/index.ts:88`](../packages/examples/agent-spine-demo/src/index.ts)
+Source: [`packages/examples/agent-spine-demo/src/index.ts:89`](../packages/examples/agent-spine-demo/src/index.ts)
## `@deepseek-ai/dsh-bash-local`
@@ -1509,6 +1512,8 @@ Source: [`packages/subagent/subagent-spawn/src/index.ts:20`](../packages/subagen
```ts config-catalog
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
export interface Config {
+ /** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */
+ includeHarnessIdentity?: boolean
/**
* Deployment-wide order-0 persona template. A scoped section named
* `deployment:persona` shadows it; `{{variable}}` references are strict.
@@ -1566,6 +1571,26 @@ export interface Config {
Source: [`packages/bash/tool-bash/src/index.ts:41`](../packages/bash/tool-bash/src/index.ts)
+## `@deepseek-ai/dsh-tool-bash-persistent`
+
+Requires: `tools` · `pty`
+
+```ts config-catalog
+/** Configuration for the persistent Bash 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-bash-persistent/src/index.ts:340`](../packages/pty/tool-bash-persistent/src/index.ts)
+
## `@deepseek-ai/dsh-tool-cordis`
Requires: `tools`
@@ -1724,6 +1749,24 @@ export interface Config {
Source: [`packages/skill/tool-skill/src/index.ts:21`](../packages/skill/tool-skill/src/index.ts)
+## `@deepseek-ai/dsh-tool-str-replace-editor`
+
+Requires: `tools` · `fs`
+
+```ts config-catalog
+/** Configuration for the string-replacement editor tool. */
+export interface Config {
+ /** Maximum returned view characters before clipping (default 16000). */
+ maxOutputChars?: number
+ /** Model-facing tool description. */
+ description?: string
+ /** Require local absolute paths like the canonical editor contract (default true). */
+ requireAbsolutePath?: boolean
+}
+```
+
+Source: [`packages/fs/tool-str-replace-editor/src/index.ts:373`](../packages/fs/tool-str-replace-editor/src/index.ts)
+
## `@deepseek-ai/dsh-tool-subagent`
Requires: `tools` · `subagents`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index e0dd17fa02..c564cf53a5 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1870,7 +1870,7 @@ async assemble(context: AssembleContext = {}): Promise
Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md)
-Source: [`packages/core/system-prompt/src/index.ts:246`](../../packages/core/system-prompt/src/index.ts)
+Source: [`packages/core/system-prompt/src/index.ts:248`](../../packages/core/system-prompt/src/index.ts)
## `ctx.tasks` — `TaskService` (abstract seam)
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 9d66b52bfb..919f891685 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
-| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
+| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../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) |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 703f4c8abf..b8fe4a23c7 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -48,6 +48,7 @@ flowchart TD
pkg_fs_sandbox["fs-sandbox"]
pkg_tool_fs["tool-fs"]
pkg_tool_fs_search["tool-fs-search"]
+ pkg_tool_str_replace_editor["tool-str-replace-editor"]
end
subgraph group_skill["packages/skill"]
pkg_skill["skill"]
@@ -202,6 +203,7 @@ flowchart TD
subgraph group_pty["packages/pty"]
pkg_pty["pty"]
pkg_pty_local["pty-local"]
+ pkg_tool_bash_persistent["tool-bash-persistent"]
pkg_tool_pty["tool-pty"]
end
subgraph group_sandbox["packages/sandbox"]
@@ -663,6 +665,9 @@ flowchart TD
pkg_tool_fs_search --> pkg_spill
pkg_tool_fs_search --> pkg_system_prompt
pkg_tool_fs_search --> pkg_tools
+ pkg_tool_str_replace_editor --> pkg_fs
+ pkg_tool_str_replace_editor --> pkg_invariants
+ pkg_tool_str_replace_editor --> pkg_tools
pkg_tool_skill --> pkg_agent
pkg_tool_skill --> pkg_invariants
pkg_tool_skill --> pkg_llm
@@ -777,6 +782,11 @@ flowchart TD
pkg_mcp_client --> pkg_llm
pkg_mcp_client --> pkg_subprocess
pkg_mcp_client --> pkg_tools
+ pkg_tool_bash_persistent --> pkg_agent
+ pkg_tool_bash_persistent --> pkg_invariants
+ pkg_tool_bash_persistent --> pkg_pty
+ pkg_tool_bash_persistent --> pkg_timeout
+ pkg_tool_bash_persistent --> pkg_tools
pkg_tool_pty --> pkg_agent
pkg_tool_pty --> pkg_invariants
pkg_tool_pty --> pkg_llm
@@ -1077,6 +1087,7 @@ flowchart TD
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
@@ -1098,6 +1109,7 @@ flowchart TD
| [`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) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
+| [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index 995a8c669d..bed13d1a2d 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -20,6 +20,8 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. |
+| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
+| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. |
@@ -279,6 +281,99 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor
Ships in examples/cordis-agent only (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`
+
+Run commands in a persistent bash 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 bash command to run. Relative path is preferred in the command."
+ }
+ },
+ "required": [
+ "command"
+ ]
+}
+```
+
+Source: [`packages/pty/tool-bash-persistent/src/index.ts`](../packages/pty/tool-bash-persistent/src/index.ts)
+
+One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.
+
+## `@deepseek-ai/dsh-tool-str-replace-editor`
+
+### `str_replace_editor`
+
+Custom editing tool for viewing, creating and editing files
+* State is persistent across command calls and discussions with the user
+* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
+* The `create` command cannot be used if the specified `path` already exists as a file
+* If a `command` generates a long output, it will be truncated and marked with ``
+
+Notes for using the `str_replace` command:
+* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
+* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique
+* The `new_str` parameter should contain the edited lines that should replace the `old_str`
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "command": {
+ "type": "string",
+ "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.",
+ "enum": [
+ "view",
+ "create",
+ "str_replace",
+ "insert"
+ ]
+ },
+ "path": {
+ "type": "string",
+ "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
+ },
+ "file_text": {
+ "type": "string",
+ "description": "Required parameter of `create` command, with the content of the file to be created."
+ },
+ "insert_line": {
+ "type": "integer",
+ "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`."
+ },
+ "new_str": {
+ "type": "string",
+ "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert."
+ },
+ "old_str": {
+ "type": "string",
+ "description": "Required parameter of `str_replace` command containing the string in `path` to replace."
+ },
+ "view_range": {
+ "type": "array",
+ "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.",
+ "items": {
+ "type": "integer"
+ }
+ }
+ },
+ "required": [
+ "command",
+ "path"
+ ]
+}
+```
+
+Source: [`packages/fs/tool-str-replace-editor/src/index.ts`](../packages/fs/tool-str-replace-editor/src/index.ts)
+
+Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface.
+
## `@deepseek-ai/dsh-tool-fs`
### `edit`
diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml
index 9d82f19bfd..cea643baa7 100644
--- a/packages/core/system-prompt/README.i18n.yaml
+++ b/packages/core/system-prompt/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: 79badba0b84b27c01f25e9c31b5df78c556411ea
-README.zh.md: 1d983e44721dbc637efc10824065aa3b88087e1d
+# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md
+README.md: 23bc0e8177ad2a778df9522e254bfd5e03a9871f
+README.zh.md: 94c4cef4e289b94e7db79a8ad4815f7d4038cf80
diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md
index 79badba0b8..23bc0e8177 100644
--- a/packages/core/system-prompt/README.md
+++ b/packages/core/system-prompt/README.md
@@ -8,6 +8,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
| Key | Default | Meaning |
|---|---|---|
+| `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by the DeepSeek Harness SDK.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. |
| `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). |
@@ -48,7 +49,7 @@ Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/imple
#### What the model sees
-Every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas.
+By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener for a deployment that owns the complete compatibility persona. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas.
##### Harness identity
@@ -58,7 +59,7 @@ You are an AI agent powered by the DeepSeek Harness SDK.
#### Token effect
-Identity is a fixed per-request cost. Persona and plugin text are repeated per request and scale with their rendered content.
+Identity is a fixed per-request cost when enabled. Persona and plugin text are repeated per request and scale with their rendered content.
#### KV Cache effect
diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md
index 1d983e4472..94c4cef4e2 100644
--- a/packages/core/system-prompt/README.zh.md
+++ b/packages/core/system-prompt/README.zh.md
@@ -8,6 +8,7 @@
| 键 | 默认值 | 含义 |
|---|---|---|
+| `includeHarnessIdentity` | `true` | 是否包含固定的 `You are an AI agent powered by the DeepSeek Harness SDK.`、顺序为 −100 的开场白。仅当兼容部署拥有完整系统提示词时设为 false。 |
| `persona` | `''` | 全局部署 persona 默认值:唯一由配置创作的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(已交付循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 |
| `toolOrder` | 无 | 显式的面向模型工具顺序:一个 `ToolSchema.name` 列表,包含一个 `''` 其余项(`TOOL_ORDER_REST`)。已列工具占据列出的位置;未列工具按名称字典序落在其余项位置。缺席 ⇒ 直接按名称字典序排列。在 `system-prompt/assemble` waterfall 之前应用于已收集工具;与段的 `order` 排序一样,它会规范化注册表贡献的内容(注册顺序是插件加载工件),而修改列表的 waterfall 监听器拥有其输出的确定性。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在已交付循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md)。 |
@@ -48,7 +49,7 @@
#### 模型所见
-每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。
+默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅为拥有完整兼容 persona 的部署省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。
##### Harness 身份
@@ -58,7 +59,7 @@ You are an AI agent powered by the DeepSeek Harness SDK.
#### Token 影响
-身份是每次请求的固定成本。Persona 与插件文本在每次请求中重复,成本随渲染内容增长。
+启用时,身份是每次请求的固定成本。Persona 与插件文本在每次请求中重复,成本随渲染内容增长。
#### KV Cache 影响
diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts
index c46c38515c..2e4e5e65b7 100644
--- a/packages/core/system-prompt/src/index.ts
+++ b/packages/core/system-prompt/src/index.ts
@@ -145,6 +145,8 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number {
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
export interface Config {
+ /** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */
+ includeHarnessIdentity?: boolean
/**
* Deployment-wide order-0 persona template. A scoped section named
* `deployment:persona` shadows it; `{{variable}}` references are strict.
@@ -245,6 +247,7 @@ class PromptLayer implements ScopeLayer {
/** Registry service for the prompt inputs assembled before each model step. */
export class SystemPrompt extends Service {
static Config: z = z.object({
+ includeHarnessIdentity: z.boolean().default(true),
persona: z.string().default(''),
// Preserve omission because an explicit empty order lacks the rest marker.
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
@@ -260,11 +263,13 @@ export class SystemPrompt extends Service {
super(ctx, 'systemPrompt')
this.toolOrder = validateToolOrder(config.toolOrder)
// Keep harness-owned openers independent of the selected loop plugin.
- this.section({
- name: 'harness:identity',
- order: -100,
- text: 'You are an AI agent powered by the DeepSeek Harness SDK.',
- })
+ if (config.includeHarnessIdentity ?? true) {
+ this.section({
+ name: 'harness:identity',
+ order: -100,
+ text: 'You are an AI agent powered by the DeepSeek Harness SDK.',
+ })
+ }
this.section({
name: 'deployment:persona',
order: 0,
diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts
index d4fdbdd684..02ac58889d 100644
--- a/packages/core/system-prompt/tests/system-prompt.spec.ts
+++ b/packages/core/system-prompt/tests/system-prompt.spec.ts
@@ -37,6 +37,18 @@ describe('SystemPrompt', () => {
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(IDENTITY)
})
+ it('can omit the harness identity for a deployment that owns the complete persona', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SystemPrompt, {
+ includeHarnessIdentity: false,
+ persona: 'You are a helpful software engineer assistant.',
+ })
+
+ const assembly = await ctx.systemPrompt.assemble()
+ expect(assembly.sections.map(section => section.name)).toEqual(['deployment:persona'])
+ expect(renderPrompt(assembly)).toBe('You are a helpful software engineer assistant.')
+ })
+
it('tolerates a schema-bypassing direct construction (persona omitted)', async () => {
// ctx.plugin validates + defaults the config first; a direct construction
// skips the schema, so the ctor's `?? ''` narrowing is what fires.
diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml
index a7c71523b2..fd6d54918e 100644
--- a/packages/examples/agent-spine-demo/README.i18n.yaml
+++ b/packages/examples/agent-spine-demo/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md
-README.md: 359e7153be2f480ba3fea4b06782acdc9f89ebb9
-README.zh.md: 57fec3f32f5bbc8f3d82ff8971d36d376d722753
+README.md: 6bbd99217bcdce0e8a0e8fd22a8d39d0c64224b9
+README.zh.md: b02b035685f1902dc9b2bd1056606495e212aa4f
diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md
index 359e7153be..6bbd99217b 100644
--- a/packages/examples/agent-spine-demo/README.md
+++ b/packages/examples/agent-spine-demo/README.md
@@ -31,7 +31,7 @@ Read this package for the whole plugin tree and its composition order.
@deepseek-ai/dsh-scope/invariant
@deepseek-ai/dsh-agent-loop/invariant
package-owned relational checks
-@deepseek-ai/dsh-tool-bash the model-facing bash schema
+@deepseek-ai/dsh-tool-bash the model-facing bash schema (unless toolBash=false)
@deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader
@deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema
@deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices
@@ -55,11 +55,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
-// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? }
+// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
-The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
+The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `includeHarnessIdentity`, `persona`, and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, set `toolBash: false` when another plugin owns the `bash` tool name, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bundled bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules.
@@ -79,5 +79,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
-- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit bundled goals, skills, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
+- **Most of the spine set is fixed in code** — `apply()` always mounts the core services; config can omit bundled goals, skills, bash, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle.
- **The invariant seam and companions remain fixed members** — `invariants.enabled: false` or package filters suppress checks but do not remove the service or companion registrations; Session's always-on validation and freezing are separate.
diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md
index 57fec3f32f..b02b035685 100644
--- a/packages/examples/agent-spine-demo/README.zh.md
+++ b/packages/examples/agent-spine-demo/README.zh.md
@@ -31,7 +31,7 @@
@deepseek-ai/dsh-scope/invariant
@deepseek-ai/dsh-agent-loop/invariant
package-owned relational checks
-@deepseek-ai/dsh-tool-bash the model-facing bash schema
+@deepseek-ai/dsh-tool-bash the model-facing bash schema (unless toolBash=false)
@deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader
@deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema
@deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices
@@ -55,11 +55,11 @@
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
-// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? }
+// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
-组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。
+组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`includeHarnessIdentity`、`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;当另一个插件拥有 `bash` 工具名时设置 `toolBash: false`;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制内置 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。
例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。
@@ -79,5 +79,5 @@ YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默
## 已知限制与延后工作
-- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务与 `tool-bash`;配置可以省略组合包内的目标、skill 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个 bundle。
+- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务;配置可以省略组合包内的目标、skill、bash 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个 bundle。
- **不变式 seam 与配套插件仍是固定成员**:`invariants.enabled: false` 或包筛选器会抑制检查,但不会移除服务或配套插件注册;Session 始终启用的校验与冻结是另一套机制。
diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts
index 92434f91da..cfa5ac3ccd 100644
--- a/packages/examples/agent-spine-demo/src/index.ts
+++ b/packages/examples/agent-spine-demo/src/index.ts
@@ -68,9 +68,9 @@ export interface GoalConfig {
/**
* Bundle config: each field forwarded verbatim to the child that owns it —
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
- * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
- * plugin (the deployment's persona section and the explicit model-facing tool
- * order), the `tools` object to the tool registry (its presentation `mode`),
+ * bridge, simply omits it), `includeHarnessIdentity`, `persona`, and `toolOrder`
+ * to the system-prompt plugin (the fixed opener, deployment persona, and explicit
+ * model-facing tool order), the `tools` object to the tool registry (its presentation `mode`),
* `dshHome` to bash environment and local skill discovery, `sessionTitle` to
* the fallback title service, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
@@ -83,13 +83,16 @@ export interface GoalConfig {
* workspace context instead requires an explicit byte budget or `false` because
* it changes model-visible input. Producer opt-in stays producer-local:
* `toolBash` configures bash only; independently composed producers keep their
- * own config.
+ * own config. Set `toolBash: false` when another plugin owns the model-facing
+ * `bash` name.
*/
export interface Config {
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
agents?: AgentLoopConfig['agents']
/** Agent-loop concurrency cap; `1` is serial. */
maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls']
+ /** Whether the system prompt includes the fixed Harness identity (default true). */
+ includeHarnessIdentity?: SystemPromptConfig['includeHarnessIdentity']
/** The deployment persona (see dsh-system-prompt's `Config`). */
persona?: SystemPromptConfig['persona']
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
@@ -104,8 +107,8 @@ export interface Config {
workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */
skills?: SkillConfig
- /** Model-facing bash tool config, including this producer's background opt-in. */
- toolBash?: toolBash.Config
+ /** Model-facing bash tool config, or false when another plugin owns `bash`. */
+ toolBash?: toolBash.Config | false
/** Generic background-task controls; set false to keep the task service without model-facing task tools. */
toolTasks?: toolTasks.Config | false
/** Global enablement and package-name filters for invariant companions. */
@@ -127,7 +130,8 @@ export const SessionTitleConfigSchema: z = SessionTitleServi
.default(EXAMPLE_SESSION_TITLE_CONFIG)
/** The bash-tool config schema exported for app packages that forward `toolBash`. */
-export const ToolBashConfigSchema: z = toolBash.Config
+export const ToolBashConfigSchema: z =
+ z.union([z.const(false), toolBash.Config])
/** The task-control-tool config schema exported for app packages that forward `toolTasks`. */
export const ToolTasksConfigSchema: z = toolTasks.Config
@@ -163,6 +167,7 @@ export const Config = z.intersect([
export function pickSpineConfig(config: Omit): Omit {
return {
...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {},
+ ...config.includeHarnessIdentity !== undefined ? { includeHarnessIdentity: config.includeHarnessIdentity } : {},
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
@@ -201,6 +206,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(SessionTitleService, config.sessionTitle ?? EXAMPLE_SESSION_TITLE_CONFIG)
// Owner schemas resolve defaults; forward toolOrder only when explicitly set.
ctx.plugin(SystemPrompt, {
+ includeHarnessIdentity: config.includeHarnessIdentity ?? true,
persona: config.persona ?? '',
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
})
@@ -223,7 +229,9 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(agentInvariant)
ctx.plugin(scopeInvariant)
ctx.plugin(agentLoopInvariant)
- ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome }))
+ if (config.toolBash !== false) {
+ ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome }))
+ }
if (config.workspaceContext !== false) {
ctx.plugin(workspaceContext, config.workspaceContext)
}
diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts
index 5a6f89525c..f32e04acf8 100644
--- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts
+++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts
@@ -4,7 +4,7 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
-import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
+import { renderPrompt, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as agentCore from '../src/index.ts'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
@@ -515,9 +515,27 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
+ it('can omit the bundled bash tool and Harness identity for a compatibility deployment', async () => {
+ const ctx = await mount({
+ includeHarnessIdentity: false,
+ persona: 'You are a helpful software engineer assistant.',
+ workspaceContext: false,
+ skills: { enabled: false },
+ toolBash: false,
+ toolTasks: false,
+ }, true)
+
+ expect(ctx.tools.schemas()).toEqual([])
+ expect(renderPrompt(await ctx.systemPrompt.assemble()))
+ .toBe('You are a helpful software engineer assistant.')
+
+ await ctx.fiber.dispose()
+ })
+
it('picks shared spine config without leaking front-door fields', () => {
const appConfig = {
model: 'front-door-only',
+ includeHarnessIdentity: false,
persona: 'You are merged.',
toolOrder: ['zulu'],
tools: { mode: 'native' as const },
@@ -531,6 +549,7 @@ describe('dsh-agent-spine-demo bundle', () => {
}
expect(agentCore.pickSpineConfig(appConfig)).toEqual({
+ includeHarnessIdentity: appConfig.includeHarnessIdentity,
persona: appConfig.persona,
toolOrder: appConfig.toolOrder,
tools: appConfig.tools,
diff --git a/packages/fs/README.i18n.yaml b/packages/fs/README.i18n.yaml
index 1ead814be1..ecfa2e134a 100644
--- a/packages/fs/README.i18n.yaml
+++ b/packages/fs/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: 4d954455ea920be4882530bcfe90b48a364c29b5
-README.zh.md: e818210abaded987edbb8bf38c6d1b43d40ad9c7
+# pnpm run verify-translation-pairing --write packages/fs/README.md
+README.md: b5e0ac9d1c0c550eb372b8a66fc6358711fddc07
+README.zh.md: c5c00f76715f97b43c673b85b3e9055a500d1b5a
diff --git a/packages/fs/README.md b/packages/fs/README.md
index 4d954455ea..b5e0ac9d1c 100644
--- a/packages/fs/README.md
+++ b/packages/fs/README.md
@@ -12,6 +12,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
+| `tool-str-replace-editor/` | Model-facing `str_replace_editor` with view/create/unique literal replace/line insert operations over `ctx.fs` | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
diff --git a/packages/fs/README.zh.md b/packages/fs/README.zh.md
index e818210aba..c5c00f7671 100644
--- a/packages/fs/README.zh.md
+++ b/packages/fs/README.zh.md
@@ -12,6 +12,7 @@
| `fs-policy/` | 政策门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) |
| `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升级字段 | (注册到 `ctx.tools`) |
| `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools`) |
+| `tool-str-replace-editor/` | 基于 `ctx.fs` 提供查看/创建/唯一字面量替换/按行插入的模型可见 `str_replace_editor` | (注册到 `ctx.tools`) |
接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、政策门禁或面向模型的工具 schema;`fs-sandbox` 是第一个这样的替代实现(基于共享沙箱模式的进程内路径围栏;见[跨能力族 fs 沙箱 Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md))。政策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam:搜索是在 bash 执行器上运行 `rg`、由进程支持的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。
diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml
new file mode 100644
index 0000000000..1f72b1a211
--- /dev/null
+++ b/packages/fs/tool-str-replace-editor/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/fs/tool-str-replace-editor/README.md
+README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b
+README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d
diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md
new file mode 100644
index 0000000000..2d98b51d56
--- /dev/null
+++ b/packages/fs/tool-str-replace-editor/README.md
@@ -0,0 +1,54 @@
+# @deepseek-ai/dsh-tool-str-replace-editor
+
+English | [中文](README.zh.md)
+
+Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed with persistent Bash, one-shot Bash, sandboxed Bash, or another terminal surface.
+
+## Config
+
+| Key | Default | Meaning |
+|---|---:|---|
+| `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. |
+| `description` | Editor command guide | Model-facing tool description. |
+| `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. |
+
+## Tool
+
+The schema provides `view`, `create`, `str_replace`, and `insert`. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline.
+
+## Model Experience
+
+### Tool schema
+
+#### What the model sees
+
+The generated [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor), including the configured `description`. The plugin contributes no standalone system-prompt section.
+
+#### Token effect
+
+Fixed schema cost while `str_replace_editor` is visible.
+
+#### KV Cache effect
+
+Prefix-stable while the configured description and schema remain unchanged.
+
+### Tool results
+
+#### What the model sees
+
+Views return numbered text or a shallow directory listing. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice.
+
+#### Token effect
+
+Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice.
+
+#### KV Cache effect
+
+Append-only tool results follow the reusable request prefix.
+
+## Known Limitations and Deferred Work
+
+- Operations target UTF-8 text; binary files are unsupported.
+- `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument.
+- Canonical mode expands tabs before replacement or insertion, matching the reference string-replacement editor.
+- The package delegates security and read-before-edit policy to the mounted filesystem and policy plugins.
diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md
new file mode 100644
index 0000000000..a2ee8f1e36
--- /dev/null
+++ b/packages/fs/tool-str-replace-editor/README.zh.md
@@ -0,0 +1,54 @@
+# @deepseek-ai/dsh-tool-str-replace-editor
+
+[English](README.md) | 中文
+
+基于 `ctx.fs` 的独立模型可见 `str_replace_editor`。它可与持久 Bash、一次性 Bash、沙箱 Bash 或其他终端表面组合。
+
+## 配置
+
+| 键 | 默认值 | 含义 |
+|---|---:|---|
+| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 |
+| `description` | 编辑器命令指南 | 面向模型的工具描述。 |
+| `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 |
+
+## 工具
+
+Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。
+
+## 模型体验
+
+### 工具 schema
+
+#### 模型所见
+
+生成的 [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor),其中包含配置的 `description`。本插件不贡献独立系统提示词段。
+
+#### Token 影响
+
+`str_replace_editor` 可见时产生固定的 schema 成本。
+
+#### KV Cache 影响
+
+配置的描述与 schema 不变时前缀稳定。
+
+### 工具结果
+
+#### 模型所见
+
+查看操作返回带行号文本或浅层目录列表。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
+
+#### Token 影响
+
+随数据变化,并受 `maxOutputChars` 与固定截断提示约束。
+
+#### KV Cache 影响
+
+工具结果以追加方式位于可复用请求前缀之后。
+
+## 已知限制与延后工作
+
+- 操作面向 UTF-8 文本,不支持二进制文件。
+- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。
+- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。
+- 安全与先读后改策略委托给挂载的文件系统和策略插件。
diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json
new file mode 100644
index 0000000000..88d3af8d53
--- /dev/null
+++ b/packages/fs/tool-str-replace-editor/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@deepseek-ai/dsh-tool-str-replace-editor",
+ "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service",
+ "version": "0.0.1",
+ "private": true,
+ "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",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-fs": "^0.0.1",
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
+ "@deepseek-ai/dsh-tools": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "dependencies": {
+ "schemastery": "^3.18.0"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-fs": "workspace:^",
+ "@deepseek-ai/dsh-fs-local": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-system-prompt": "workspace:^",
+ "@deepseek-ai/dsh-tools": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts
new file mode 100644
index 0000000000..c4a16e5437
--- /dev/null
+++ b/packages/fs/tool-str-replace-editor/src/index.ts
@@ -0,0 +1,403 @@
+/**
+ * Model-facing `str_replace_editor` over the Harness filesystem seam.
+ * @module @deepseek-ai/dsh-tool-str-replace-editor
+ */
+
+import { isAbsolute } from 'node:path'
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import { FsError } from '@deepseek-ai/dsh-fs'
+import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs'
+import { defineTool } from '@deepseek-ai/dsh-tools'
+import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
+
+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 `grep -n` in order to find the line numbers of what you are looking for. '
+
+const DEFAULT_DESCRIPTION = `
+Custom editing tool for viewing, creating and editing files
+* State is persistent across command calls and discussions with the user
+* If \`path\` is a file, \`view\` displays the result of applying \`cat -n\`. If \`path\` is a directory, \`view\` lists non-hidden files and directories up to 2 levels deep
+* The \`create\` command cannot be used if the specified \`path\` already exists as a file
+* If a \`command\` generates a long output, it will be truncated and marked with \`\`
+
+Notes for using the \`str_replace\` command:
+* The \`old_str\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
+* If the \`old_str\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \`old_str\` to make it unique
+* The \`new_str\` parameter should contain the edited lines that should replace the \`old_str\`
+`.trim()
+
+function maybeTruncate(content: string, maxOutputChars: number): string {
+ return content.length <= maxOutputChars
+ ? content
+ : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
+}
+
+function expandTabs(content: string, tabSize = 8): string {
+ let column = 0
+ let result = ''
+ for (const character of content) {
+ if (character === '\t') {
+ const spaces = tabSize - (column % tabSize)
+ result += ' '.repeat(spaces)
+ column += spaces
+ continue
+ }
+ result += character
+ if (character === '\n' || character === '\r') column = 0
+ else column += 1
+ }
+ return result
+}
+
+async function resolveTarget(
+ ctx: Context,
+ path: string,
+ requireAbsolutePath: boolean,
+ exec: ToolRunContext,
+): Promise {
+ if (path.trim().length === 0) throw new Error('path must be a non-empty string')
+ if (requireAbsolutePath && !isAbsolute(path)) {
+ throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`)
+ }
+ const cwd = exec.agent?.session.header.cwd
+ return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal })
+}
+
+async function statExisting(
+ ctx: Context,
+ target: FsTarget,
+ command: 'view' | 'str_replace' | 'insert',
+ exec: ToolRunContext,
+): Promise {
+ const info = await ctx.fs.stat(target, exec.signal)
+ if (info === undefined) {
+ throw new FsError(
+ `The path ${target.displayPath} does not exist. Please provide a valid path.`,
+ 'FS_NOT_FOUND',
+ )
+ }
+ if (info.type === 'directory' && command !== 'view') {
+ throw new FsError(
+ `The path ${target.displayPath} is a directory and only the \`view\` command can be used on directories`,
+ 'FS_NOT_REGULAR_FILE',
+ )
+ }
+ return info
+}
+
+function requiredForCommand(
+ value: string | undefined,
+ parameter: string,
+ command: string,
+ allowEmpty = true,
+): string {
+ if (value === undefined) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`)
+ if (!allowEmpty && value.length === 0) {
+ throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`)
+ }
+ return value
+}
+
+function formatFileView(
+ path: string,
+ content: string,
+ maxOutputChars: number,
+ viewRange?: number[],
+): string {
+ const allLines = content.split('\n')
+ let lines = allLines
+ let initialLine = 1
+ let finalLine: number | undefined
+ let prompt = `Here's the content of ${path} with line numbers (which has a total of ${allLines.length} lines)`
+ if (viewRange !== undefined) {
+ const [requestedInitialLine, requestedFinalLine] = viewRange
+ if (
+ viewRange.length !== 2
+ || requestedInitialLine === undefined
+ || requestedFinalLine === undefined
+ || !viewRange.every(Number.isInteger)
+ ) {
+ throw new Error('Invalid `view_range`. It should be a list of two integers.')
+ }
+ initialLine = requestedInitialLine
+ finalLine = requestedFinalLine
+ if (initialLine < 1 || initialLine > allLines.length) {
+ throw new Error(
+ `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`,
+ )
+ }
+ if (finalLine > allLines.length) {
+ throw new Error(
+ `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``,
+ )
+ }
+ if (finalLine !== -1 && finalLine < initialLine) {
+ throw new Error(
+ `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``,
+ )
+ }
+ lines = finalLine === -1
+ ? allLines.slice(initialLine - 1)
+ : allLines.slice(initialLine - 1, finalLine)
+ prompt += ` with view_range=[${initialLine}, ${finalLine}]`
+ }
+ const numbered = expandTabs(lines
+ .map((line, index) => `${String(initialLine + index).padStart(6, ' ')}\t${line}`)
+ .join('\n'))
+ return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars)
+}
+
+async function listDirectory(
+ ctx: Context,
+ target: FsTarget,
+ maxOutputChars: number,
+ exec: ToolRunContext,
+): Promise {
+ async function visit(dir: FsTarget, depth: number): Promise {
+ const entries = await ctx.fs.listDir(dir, exec.signal)
+ const rows: string[] = []
+ for (const entry of entries.filter(candidate =>
+ !candidate.name.startsWith('.')
+ && !candidate.name.startsWith('node_modules')
+ && !candidate.name.startsWith('__pycache__'))) {
+ const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?'
+ rows.push(`${type}\t${entry.target.displayPath}`)
+ if (entry.type === 'directory' && depth < 2) {
+ rows.push(...await visit(entry.target, depth + 1))
+ }
+ }
+ return rows
+ }
+ const rows = [`d\t${target.displayPath}`, ...await visit(target, 1)]
+ rows.sort((left, right) => {
+ const leftPath = left.slice(left.indexOf('\t') + 1)
+ const rightPath = right.slice(right.indexOf('\t') + 1)
+ return leftPath.localeCompare(rightPath)
+ })
+ const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars)
+ return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n`
+}
+
+async function viewPath(
+ ctx: Context,
+ path: string,
+ viewRange: number[] | undefined,
+ maxOutputChars: number,
+ requireAbsolutePath: boolean,
+ exec: ToolRunContext,
+): Promise {
+ const target = await resolveTarget(ctx, path, requireAbsolutePath, exec)
+ const info = await statExisting(ctx, target, 'view', exec)
+ if (info.type === 'directory') {
+ if (viewRange !== undefined) {
+ throw new Error('The `view_range` parameter is not allowed when `path` points to a directory.')
+ }
+ return listDirectory(ctx, target, maxOutputChars, exec)
+ }
+ if (info.type !== 'file') {
+ throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, 'FS_NOT_REGULAR_FILE')
+ }
+ const content = await ctx.fs.readText(target, exec.signal)
+ ctx.emit('fs/observed', target, info.version, exec)
+ return formatFileView(target.displayPath, content, maxOutputChars, viewRange)
+}
+
+async function createFile(
+ ctx: Context,
+ path: string,
+ fileText: string | undefined,
+ requireAbsolutePath: boolean,
+ exec: ToolRunContext,
+): Promise {
+ const content = requiredForCommand(fileText, 'file_text', 'create')
+ const target = await resolveTarget(ctx, path, requireAbsolutePath, exec)
+ if (await ctx.fs.stat(target, exec.signal) !== undefined) {
+ throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`)
+ }
+ const outcome = await ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, exec.signal)
+ ctx.emit('fs/observed', target, outcome.version, exec)
+ return `New file created successfully at: ${target.displayPath}`
+}
+
+async function replaceInFile(
+ ctx: Context,
+ path: string,
+ oldStr: string | undefined,
+ newStr: string | undefined,
+ requireAbsolutePath: boolean,
+ exec: ToolRunContext,
+): Promise {
+ const target = await resolveTarget(ctx, path, requireAbsolutePath, exec)
+ const oldValue = expandTabs(requiredForCommand(oldStr, 'old_str', 'str_replace', false))
+ const newValue = expandTabs(newStr ?? '')
+ const info = await statExisting(ctx, target, 'str_replace', exec)
+ if (info.type !== 'file') {
+ throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
+ }
+ const before = expandTabs(await ctx.fs.readText(target, exec.signal))
+ const occurrences = before.split(oldValue).length - 1
+ if (occurrences === 0) {
+ throw new FsError(
+ `No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`,
+ 'FS_EDIT_NOT_FOUND',
+ )
+ }
+ if (occurrences > 1) {
+ const lines = before.split('\n')
+ .flatMap((line, index) => line.includes(oldValue) ? [index + 1] : [])
+ throw new FsError(
+ `No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`,
+ 'FS_AMBIGUOUS_EDIT',
+ )
+ }
+ const outcome = await ctx.fs.writeText(
+ target,
+ before.replace(oldValue, newValue),
+ { kind: 'replaceIfVersion', version: info.version },
+ exec.signal,
+ )
+ ctx.emit('fs/observed', target, outcome.version, exec)
+ return `The file ${target.displayPath} has been edited successfully.`
+}
+
+async function insertInFile(
+ ctx: Context,
+ path: string,
+ insertLine: number | undefined,
+ newStr: string | undefined,
+ requireAbsolutePath: boolean,
+ exec: ToolRunContext,
+): Promise {
+ if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert')
+ const value = expandTabs(requiredForCommand(newStr, 'new_str', 'insert'))
+ const target = await resolveTarget(ctx, path, requireAbsolutePath, exec)
+ const info = await statExisting(ctx, target, 'insert', exec)
+ if (info.type !== 'file') {
+ throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
+ }
+ const before = expandTabs(await ctx.fs.readText(target, exec.signal))
+ const lines = before.split('\n')
+ if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) {
+ throw new Error(
+ `Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`,
+ )
+ }
+ const after = [
+ ...lines.slice(0, insertLine),
+ ...value.split('\n'),
+ ...lines.slice(insertLine),
+ ].join('\n')
+ const outcome = await ctx.fs.writeText(
+ target,
+ after,
+ { kind: 'replaceIfVersion', version: info.version },
+ exec.signal,
+ )
+ ctx.emit('fs/observed', target, outcome.version, exec)
+ return `The file ${target.displayPath} has been edited successfully.`
+}
+
+interface ResolvedConfig {
+ maxOutputChars: number
+ description: string
+ requireAbsolutePath: boolean
+}
+
+/** Register the model-facing `str_replace_editor` tool. */
+function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void {
+ ctx.tools.register(defineTool({
+ name: 'str_replace_editor',
+ description: config.description,
+ parameters: {
+ command: {
+ type: 'string',
+ required: true,
+ enum: ['view', 'create', 'str_replace', 'insert'],
+ description: 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.',
+ },
+ path: {
+ type: 'string',
+ required: true,
+ description: 'Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.',
+ },
+ file_text: {
+ type: 'string',
+ description: 'Required parameter of `create` command, with the content of the file to be created.',
+ },
+ insert_line: {
+ type: 'integer',
+ description: 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.',
+ },
+ new_str: {
+ type: 'string',
+ description: 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.',
+ },
+ old_str: {
+ type: 'string',
+ description: 'Required parameter of `str_replace` command containing the string in `path` to replace.',
+ },
+ view_range: {
+ type: 'array',
+ items: { type: 'integer' },
+ description: 'Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.',
+ },
+ },
+ output: {
+ schema: { type: 'string' },
+ render: (_args, value) => [{ type: 'text', text: value }],
+ },
+ async execute(args, exec) {
+ switch (args.command) {
+ case 'view':
+ return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec)
+ case 'create':
+ return createFile(ctx, args.path, args.file_text, config.requireAbsolutePath, exec)
+ case 'str_replace':
+ return replaceInFile(ctx, args.path, args.old_str, args.new_str, config.requireAbsolutePath, exec)
+ case 'insert':
+ return insertInFile(ctx, args.path, args.insert_line, args.new_str, config.requireAbsolutePath, exec)
+ }
+ },
+ presentCall: args => ({
+ card: 'generic',
+ title: `${args.command} ${args.path}`,
+ kind: args.command === 'view' ? 'read' : 'edit',
+ }),
+ }))
+}
+
+export const name = 'tool-str-replace-editor'
+export const inject = ['tools', 'fs']
+
+/** Configuration for the string-replacement editor tool. */
+export interface Config {
+ /** Maximum returned view characters before clipping (default 16000). */
+ maxOutputChars?: number
+ /** Model-facing tool description. */
+ description?: string
+ /** Require local absolute paths like the canonical editor contract (default true). */
+ requireAbsolutePath?: boolean
+}
+
+/** Runtime configuration schema for the string-replacement editor tool. */
+export const Config: z = z.object({
+ maxOutputChars: z.number().default(16_000),
+ description: z.string().default(DEFAULT_DESCRIPTION),
+ requireAbsolutePath: z.boolean().default(true),
+})
+
+/** Register one `str_replace_editor` tool over `ctx.fs`. */
+export function apply(ctx: Context, config: Config): void {
+ const resolved: ResolvedConfig = {
+ maxOutputChars: config.maxOutputChars ?? 16_000,
+ description: config.description ?? DEFAULT_DESCRIPTION,
+ requireAbsolutePath: config.requireAbsolutePath ?? true,
+ }
+ if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
+ throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer')
+ }
+ if (resolved.description.trim().length === 0) {
+ throw new Error('tool-str-replace-editor: description must be non-empty')
+ }
+ registerStrReplaceEditor(ctx, resolved)
+}
diff --git a/packages/fs/tool-str-replace-editor/src/invariant.ts b/packages/fs/tool-str-replace-editor/src/invariant.ts
new file mode 100644
index 0000000000..99547c02ee
--- /dev/null
+++ b/packages/fs/tool-str-replace-editor/src/invariant.ts
@@ -0,0 +1,30 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-tool-str-replace-editor`.
+ * @module @deepseek-ai/dsh-tool-str-replace-editor/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-tool-str-replace-editor'
+
+/** Cordis companion plugin name. */
+export const name = 'tool-str-replace-editor-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the tool adapter owns no independent durable state;
+ * filesystem mutation relations stay with the provider and policy plugins.
+ */
+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/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts
new file mode 100644
index 0000000000..7cf9ba5212
--- /dev/null
+++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts
@@ -0,0 +1,313 @@
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { FsVersion } from '@deepseek-ai/dsh-fs'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
+
+const contexts: Context[] = []
+const roots: string[] = []
+let callNumber = 0
+
+afterEach(async () => {
+ for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+ for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
+function agent(ctx: Context, cwd: string): Agent {
+ const id = SessionId(`str-replace-editor-owner-${callNumber}`)
+ const scope = ctx.plugin(() => {})
+ const value: Agent = {
+ id,
+ options: {},
+ session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }),
+ status: 'idle',
+ acceptsNextStep: false,
+ ctx: scope.ctx,
+ followup: () => {},
+ steer: () => {},
+ inject: () => {},
+ send: () => {},
+ cancel() {},
+ 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, args: unknown) {
+ return ctx.tools.execute({
+ signal: new AbortController().signal,
+ callId: CallId(`str-replace-editor-${++callNumber}`),
+ name: 'str_replace_editor',
+ arguments: args,
+ ...owner === undefined ? {} : { agent: owner },
+ })
+}
+
+async function setup(config: ToolStrReplaceEditor.Config = {}) {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-'))
+ roots.push(root)
+ const ctx = new Context()
+ contexts.push(ctx)
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(LocalFileSystem, { cwd: root })
+ await ctx.plugin(ToolStrReplaceEditor, config)
+ return { ctx, root, owner: agent(ctx, root) }
+}
+
+describe('tool-str-replace-editor', () => {
+ it('registers the standalone schema and configurable description', async () => {
+ const { ctx } = await setup({ description: 'custom editor description' })
+ const schema = ctx.tools.schemas()[0]
+ expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor'])
+ expect(schema?.description).toBe('custom editor description')
+ const properties = (schema?.parameters as {
+ properties: Record
+ }).properties
+ expect(properties).not.toHaveProperty('replace_all')
+ expect(properties.insert_line?.type).toBe('integer')
+ expect(properties.view_range?.items?.type).toBe('integer')
+ expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
+ command: 'view',
+ path: '/workspace/a.txt',
+ })).toMatchObject({ card: 'generic', kind: 'read' })
+ expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
+ command: 'insert',
+ path: '/workspace/a.txt',
+ insert_line: 0,
+ new_str: 'x',
+ })).toMatchObject({ card: 'generic', kind: 'edit' })
+ })
+
+ it('creates, views, replaces, and inserts with the canonical model-facing output', async () => {
+ const { ctx, root, owner } = await setup()
+ const sample = join(root, 'sample.txt')
+ expect(text(await call(ctx, owner, {
+ command: 'create',
+ path: sample,
+ file_text: 'one\ntwo\nthree\n',
+ }))).toBe(`New file created successfully at: ${sample}`)
+
+ expect(text(await call(ctx, owner, {
+ command: 'view',
+ path: sample,
+ view_range: [2, -1],
+ }))).toBe([
+ `Here's the content of ${sample} with line numbers (which has a total of 4 lines) with view_range=[2, -1]:`,
+ ' 2 two',
+ ' 3 three',
+ ' 4 ',
+ '',
+ ].join('\n'))
+
+ expect(text(await call(ctx, owner, {
+ command: 'str_replace',
+ path: sample,
+ old_str: 'two',
+ new_str: 'TWO',
+ }))).toBe(`The file ${sample} has been edited successfully.`)
+ expect(text(await call(ctx, owner, {
+ command: 'str_replace',
+ path: sample,
+ old_str: 'TWO',
+ }))).toBe(`The file ${sample} has been edited successfully.`)
+ expect(text(await call(ctx, owner, {
+ command: 'insert',
+ path: sample,
+ insert_line: 1,
+ new_str: 'between',
+ }))).toBe(`The file ${sample} has been edited successfully.`)
+ expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n')
+ })
+
+ it('lists visible entries to depth two and clips at the configured view limit', async () => {
+ const { ctx, root, owner } = await setup({ maxOutputChars: 10 })
+ await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true })
+ await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true })
+ await mkdir(join(root, 'dir', '__pycache__'), { recursive: true })
+ await writeFile(join(root, 'dir', 'visible.txt'), 'ok')
+ await writeFile(join(root, 'dir', '.hidden'), 'hidden')
+ await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child')
+ await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep')
+ await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency')
+ await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache')
+ const listDir = ctx.fs.listDir.bind(ctx.fs)
+ const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other'))
+ ctx.fs.listDir = async (target, signal) => {
+ const entries = await listDir(target, signal)
+ return target.displayPath === join(root, 'dir')
+ ? [...entries, { name: 'other', type: 'other', target: otherTarget }]
+ : entries
+ }
+
+ const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') }))
+ expect(listing).toContain('')
+ expect(listing).not.toContain('.hidden')
+ expect(listing).not.toContain('too-deep.txt')
+ expect(listing).not.toContain('index.js')
+ expect(listing).not.toContain('module.pyc')
+
+ await writeFile(join(root, 'large.txt'), 'x'.repeat(100))
+ expect(text(await call(ctx, owner, { command: 'view', path: join(root, 'large.txt') })))
+ .toContain('')
+ })
+
+ it('matches canonical empty-line, range, and end-insert behavior', async () => {
+ const { ctx, root, owner } = await setup()
+ const empty = join(root, 'empty.txt')
+ const newline = join(root, 'newline.txt')
+ const plain = join(root, 'plain.txt')
+ await writeFile(empty, '')
+ await writeFile(newline, '\n')
+ await writeFile(plain, 'one\ntwo')
+
+ expect(text(await call(ctx, owner, { command: 'view', path: empty })))
+ .toContain('(which has a total of 1 lines):\n 1 \n')
+ expect(text(await call(ctx, owner, { command: 'view', path: newline })))
+ .toContain('(which has a total of 2 lines):\n 1 \n 2 \n')
+ expect(text(await call(ctx, owner, {
+ command: 'view',
+ path: plain,
+ view_range: [1, 2],
+ }))).toContain(' 2 two')
+ expect(text(await call(ctx, undefined, {
+ command: 'view',
+ path: plain,
+ }))).toContain(' 1 one')
+
+ await call(ctx, owner, {
+ command: 'insert',
+ path: plain,
+ insert_line: 2,
+ new_str: 'three',
+ })
+ expect(await readFile(plain, 'utf8')).toBe('one\ntwo\nthree')
+
+ await writeFile(newline, 'one\n')
+ await call(ctx, owner, {
+ command: 'insert',
+ path: newline,
+ insert_line: 2,
+ new_str: 'three',
+ })
+ expect(await readFile(newline, 'utf8')).toBe('one\n\nthree')
+ })
+
+ it('uses old_str-only replacement failures and rejects relative paths', async () => {
+ const { ctx, root, owner } = await setup()
+ const ambiguous = join(root, 'ambiguous.txt')
+ await writeFile(ambiguous, 'same\nother\nsame')
+
+ const missing = await call(ctx, owner, {
+ command: 'str_replace',
+ path: ambiguous,
+ old_str: 'absent',
+ new_str: 'x',
+ })
+ expect(missing.isError).toBe(true)
+ expect(text(missing)).toContain(`old_str \`absent\` did not appear verbatim in ${ambiguous}`)
+ expect(text(missing)).not.toContain('old_string')
+
+ const repeated = await call(ctx, owner, {
+ command: 'str_replace',
+ path: ambiguous,
+ old_str: 'same',
+ new_str: 'x',
+ })
+ expect(repeated.isError).toBe(true)
+ expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]')
+ expect(text(repeated)).not.toContain('replace_all')
+
+ const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' })
+ expect(relative.isError).toBe(true)
+ expect(text(relative)).toContain('is not an absolute path')
+ expect(await readFile(ambiguous, 'utf8')).toBe('same\nother\nsame')
+ })
+
+ it('reports invalid commands or arguments without mutating files', async () => {
+ const { ctx, root, owner } = await setup()
+ const ambiguous = join(root, 'ambiguous.txt')
+ const empty = join(root, 'empty.txt')
+ const trailingNewline = join(root, 'trailing-newline.txt')
+ const threeLines = join(root, 'three-lines.txt')
+ const directory = join(root, 'directory')
+ await writeFile(ambiguous, 'same same')
+ await writeFile(empty, '')
+ await writeFile(trailingNewline, 'one\n')
+ await writeFile(threeLines, 'one\ntwo\nthree')
+ await mkdir(directory)
+
+ const cases = [
+ { command: 'view', path: '' },
+ { command: 'view', path: join(root, 'missing.txt') },
+ { command: 'view', path: ambiguous, view_range: [1] },
+ { command: 'view', path: ambiguous, view_range: [0, 1] },
+ { command: 'view', path: ambiguous, view_range: [1.5, 2] },
+ { command: 'view', path: threeLines, view_range: [1, 99] },
+ { command: 'view', path: threeLines, view_range: [2, 1] },
+ { command: 'view', path: directory, view_range: [1, 1] },
+ { command: 'create', path: join(root, 'new.txt') },
+ { command: 'create', path: ambiguous, file_text: 'overwrite' },
+ { command: 'str_replace', path: ambiguous, new_str: 'x' },
+ { command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' },
+ { command: 'insert', path: ambiguous, new_str: 'x' },
+ { command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' },
+ { command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' },
+ { command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' },
+ { command: 'insert', path: empty, insert_line: 2, new_str: 'x' },
+ { command: 'insert', path: directory, insert_line: 0, new_str: 'x' },
+ ]
+ for (const args of cases) {
+ expect((await call(ctx, owner, args)).isError).toBe(true)
+ }
+ expect(await readFile(ambiguous, 'utf8')).toBe('same same')
+
+ ctx.fs.stat = async () => ({ version: FsVersion('special'), type: 'other' })
+ const special = await call(ctx, owner, { command: 'view', path: join(root, 'special') })
+ expect(special.isError).toBe(true)
+ expect(special.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
+ expect((await call(ctx, owner, {
+ command: 'str_replace',
+ path: join(root, 'special'),
+ old_str: 'x',
+ new_str: 'y',
+ })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
+ expect((await call(ctx, owner, {
+ command: 'insert',
+ path: join(root, 'special'),
+ insert_line: 0,
+ new_str: 'x',
+ })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
+ })
+
+ it('can opt into session-relative paths for non-canonical deployments', async () => {
+ const { ctx, root, owner } = await setup({ requireAbsolutePath: false })
+ await writeFile(join(root, 'relative.txt'), 'relative')
+ expect(text(await call(ctx, owner, { command: 'view', path: 'relative.txt' })))
+ .toContain("Here's the content of")
+ })
+
+ it('rejects invalid plugin config', () => {
+ expect(() => {
+ ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 })
+ }).toThrow('maxOutputChars must be a positive safe integer')
+ expect(() => {
+ ToolStrReplaceEditor.apply(new Context(), { description: ' ' })
+ }).toThrow('description must be non-empty')
+ })
+})
diff --git a/packages/fs/tool-str-replace-editor/tsconfig.json b/packages/fs/tool-str-replace-editor/tsconfig.json
new file mode 100644
index 0000000000..2c6eb3688c
--- /dev/null
+++ b/packages/fs/tool-str-replace-editor/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": ["src"],
+ "references": [
+ { "path": "../../../vendor/cordis" },
+ { "path": "../../core/tools" },
+ { "path": "../fs" },
+ { "path": "../../support/invariants" }
+ ]
+}
diff --git a/packages/pty/README.i18n.yaml b/packages/pty/README.i18n.yaml
index ef4c7b5c13..f986146eae 100644
--- a/packages/pty/README.i18n.yaml
+++ b/packages/pty/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: a9121455519a5f83a63a005cb857fec0f0e06b92
-README.zh.md: 9fc262787b960d5bf03a59cd01bf36bd5c76614b
+# pnpm run verify-translation-pairing --write packages/pty/README.md
+README.md: e54dcf64db083665f37b7dc19a7a92e21494442b
+README.zh.md: e06ea496e389d329671c358fbde5c0849aa03cf8
diff --git a/packages/pty/README.md b/packages/pty/README.md
index a912145551..e54dcf64db 100644
--- a/packages/pty/README.md
+++ b/packages/pty/README.md
@@ -9,5 +9,6 @@ English | [中文](README.zh.md)
| [`pty`](pty/README.md) (`@deepseek-ai/dsh-pty`) | Backend registry, branded ids, exact-Agent ownership, session operations, and awaited cleanup | `ctx.pty` |
| `pty-local` (`@deepseek-ai/dsh-pty-local`) | Local `node-pty` backend, readiness detection, bounded terminal state, sandboxing, and process-session supervision | registers on `ctx.pty` |
| `tool-pty` (`@deepseek-ai/dsh-tool-pty`) | Six model-facing tools and generic task integration for background sends | registers on `ctx.tools` |
+| `tool-bash-persistent` (`@deepseek-ai/dsh-tool-bash-persistent`) | One model-facing `bash` backed by an owner-scoped reusable PTY shell | consumes `ctx.pty`, registers on `ctx.tools` |
The design and deferred boundaries live in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md).
diff --git a/packages/pty/README.zh.md b/packages/pty/README.zh.md
index 9fc262787b..e06ea496e3 100644
--- a/packages/pty/README.zh.md
+++ b/packages/pty/README.zh.md
@@ -9,5 +9,6 @@
| [`pty`](pty/README.md)(`@deepseek-ai/dsh-pty`) | 后端注册表、品牌化 id、精确的 Agent 所有权、会话操作与等待完成的清理 | `ctx.pty` |
| `pty-local`(`@deepseek-ai/dsh-pty-local`) | 本地 `node-pty` 后端、就绪检测、有界终端状态、沙箱与进程会话监管 | 注册到 `ctx.pty` |
| `tool-pty`(`@deepseek-ai/dsh-tool-pty`) | 6 个面向模型的工具,并为后台发送集成通用任务 | 注册到 `ctx.tools` |
+| `tool-bash-persistent`(`@deepseek-ai/dsh-tool-bash-persistent`) | 一个由所有者隔离可复用 PTY shell 支撑的模型可见 `bash` | 消费 `ctx.pty`,注册到 `ctx.tools` |
设计与暂缓边界记录在[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中。
diff --git a/packages/pty/tool-bash-persistent/README.i18n.yaml b/packages/pty/tool-bash-persistent/README.i18n.yaml
new file mode 100644
index 0000000000..2f15d109c1
--- /dev/null
+++ b/packages/pty/tool-bash-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-bash-persistent/README.md
+README.md: 04c714d5489dbae8572e9339a4387a148450a0e9
+README.zh.md: adfb38b10409174d9558b963f5a2359cf819f04b
diff --git a/packages/pty/tool-bash-persistent/README.md b/packages/pty/tool-bash-persistent/README.md
new file mode 100644
index 0000000000..04c714d548
--- /dev/null
+++ b/packages/pty/tool-bash-persistent/README.md
@@ -0,0 +1,50 @@
+# @deepseek-ai/dsh-tool-bash-persistent
+
+English | [中文](README.zh.md)
+
+Model-facing `bash(command)` backed by one owner-scoped `ctx.pty` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend and sandbox policy.
+
+## 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` | Prefix characters retained before the clipping notice. |
+| `description` | Persistent-shell description | Model-facing environment contract. |
+
+## Model Experience
+
+### Tool schema
+
+#### What the model sees
+
+The generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-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 `bash` 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, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and tells the model that the next call starts fresh.
+
+#### Token effect
+
+Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice.
+
+#### 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.
+- Explicit `exit`, timeout, or cancellation discards shell state; the next call starts a fresh shell.
+- Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default.
diff --git a/packages/pty/tool-bash-persistent/README.zh.md b/packages/pty/tool-bash-persistent/README.zh.md
new file mode 100644
index 0000000000..adfb38b104
--- /dev/null
+++ b/packages/pty/tool-bash-persistent/README.zh.md
@@ -0,0 +1,50 @@
+# @deepseek-ai/dsh-tool-bash-persistent
+
+[English](README.md) | 中文
+
+模型可见的 `bash(command)`,底层复用一个按所有者隔离的 `ctx.pty` shell。该包拥有工具契约和 shell 复用;PTY 后端与沙箱策略由部署选择。
+
+## 配置
+
+| 键 | 默认值 | 含义 |
+|---|---:|---|
+| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY 后端。 |
+| `timeoutMs` | `300000` | 单条命令的墙钟时间上限;超时会关闭 shell。 |
+| `maxOutputChars` | `16000` | 截断提示前保留的前缀字符数。 |
+| `description` | 持久 shell 描述 | 面向模型的环境契约。 |
+
+## 模型体验
+
+### 工具 schema
+
+#### 模型所见
+
+生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent),其中包含配置的 `description`。本插件不贡献独立系统提示词段;persona 与环境指导由部署负责。
+
+#### Token 影响
+
+`bash` 可见时产生固定的 schema 成本。
+
+#### KV Cache 影响
+
+配置的描述与 schema 不变时前缀稳定。
+
+### 工具结果
+
+#### 模型所见
+
+每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并告知模型下次调用从新 shell 开始。
+
+#### Token 影响
+
+随数据变化,并受 `maxOutputChars` 与固定截断提示约束。
+
+#### KV Cache 影响
+
+工具结果以追加方式位于可复用请求前缀之后。
+
+## 已知限制与延后工作
+
+- 工具需要拥有它的 Agent 和真实 PTY 后端。
+- 显式 `exit`、超时或取消会丢弃 shell 状态;下次调用创建新 shell。
+- 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。
diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json
new file mode 100644
index 0000000000..5022026733
--- /dev/null
+++ b/packages/pty/tool-bash-persistent/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@deepseek-ai/dsh-tool-bash-persistent",
+ "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service",
+ "version": "0.0.1",
+ "private": true,
+ "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",
+ "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",
+ "@deepseek-ai/dsh-pty": "^0.0.1",
+ "@deepseek-ai/dsh-timeout": "^0.0.1",
+ "@deepseek-ai/dsh-tools": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "dependencies": {
+ "schemastery": "^3.18.0"
+ },
+ "devDependencies": {
+ "@cordisjs/plugin-include": "workspace:^",
+ "@cordisjs/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-sandbox": "workspace:^",
+ "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-system-prompt": "workspace:^",
+ "@deepseek-ai/dsh-timeout": "workspace:^",
+ "@deepseek-ai/dsh-tools": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts
new file mode 100644
index 0000000000..b812fe8992
--- /dev/null
+++ b/packages/pty/tool-bash-persistent/src/index.ts
@@ -0,0 +1,380 @@
+/**
+ * Model-facing persistent `bash` tool over the owner-scoped PTY seam.
+ * @module @deepseek-ai/dsh-tool-bash-persistent
+ */
+
+import { randomUUID } from 'node:crypto'
+import type { Context } from 'cordis'
+import z from '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'
+
+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 `grep -n` 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 bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.'
+const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
+const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT'
+const SCROLLBACK_PAGE_LINES = 1_000
+
+const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash 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
+}
+
+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_BASH_START_${nonce}__`,
+ end: `__DSH_PERSISTENT_BASH_END_${nonce}:`,
+ }
+}
+
+function quoteForBash(value: string): string {
+ return `$'${value
+ .replaceAll('\\', '\\\\')
+ .replaceAll("'", "\\'")
+ .replaceAll('\r', '\\r')
+ .replaceAll('\n', '\\n')}'`
+}
+
+function wrapCommand(command: string, marker: CommandMarkers): string {
+ // Keep the wrapper on one physical line. An interactive bash prints PS2 for
+ // embedded newlines before executing the buffer, which would leak terminal
+ // prompts and marker source text into the model-facing result.
+ return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __dsh_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__dsh_persistent_bash_status"`
+}
+
+function stripPrompt(text: string): string {
+ let result = text
+ while (result.endsWith(`${SHELL_PROMPT}\r\n`) || result.endsWith(`${SHELL_PROMPT}\n`)) {
+ result = result.slice(0, result.endsWith('\r\n')
+ ? -SHELL_PROMPT.length - 2
+ : -SHELL_PROMPT.length - 1)
+ }
+ 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,
+): CapturedOutput | undefined {
+ const text = snapshot.text
+ const end = text.lastIndexOf(marker.end)
+ if (end < 0) return undefined
+ const startMarker = text.lastIndexOf(marker.start, end)
+ const start = startMarker < 0 ? 0 : startMarker + marker.start.length
+ return {
+ text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
+ incomplete: startMarker < 0 || snapshot.truncated,
+ }
+}
+
+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,
+ fallback: string,
+): 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: snapshot.truncated,
+ }
+ }
+ return {
+ text: stripPrompt(fallback),
+ incomplete: snapshot.truncated,
+ }
+}
+
+async function pause(): Promise {
+ await new Promise(resolve => setTimeout(resolve, 25))
+}
+
+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,
+): RetainedOutput {
+ const pages: string[] = []
+ let offset = 0
+ let truncated = false
+ while (true) {
+ 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)
+ return output.incomplete && output.text.length > 0
+ ? LOST_PREFIX_MESSAGE + rendered
+ : rendered
+}
+
+function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
+ const pending = new WeakMap>()
+ const live = new Map()
+ const ownerCleanupInstalled = new WeakSet()
+
+ 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 () => {
+ const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') })
+ await Promise.all(closing)
+ live.clear()
+ }, 'tool-bash-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 creating = (async () => {
+ try {
+ const cwd = owner.session.header.cwd
+ const spawned = await ctx.pty.spawn(owner, {
+ type: config.backendType,
+ ...cwd === undefined ? {} : { cwd },
+ }, signal)
+ live.set(owner, spawned.sessionId)
+ if (!ownerCleanupInstalled.has(owner)) {
+ ownerCleanupInstalled.add(owner)
+ owner.ctx.effect(() => () => {
+ pending.delete(owner)
+ live.delete(owner)
+ }, 'tool-bash-persistent owner cache cleanup')
+ }
+ const setup = ctx.pty.startSend(owner, spawned.sessionId, {
+ text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`,
+ submit: true,
+ signal,
+ })
+ const result = await setup.done
+ if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
+ throw new Error('persistent bash shell did not accept initialization')
+ }
+ return spawned.sessionId
+ } catch (error: unknown) {
+ await reset(owner, 'persistent bash initialization failed')
+ throw error
+ }
+ })()
+ pending.set(owner, creating)
+ return creating
+ }
+
+ 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 = ''
+
+ while (true) {
+ const operation = ctx.pty.startSend(owner, id, {
+ text: first ? wrapped : '',
+ submit: first,
+ signal: commandDeadline.signal,
+ })
+ first = false
+ const result = await operation.done
+ fallback += result.viewport
+ const snapshot = retainedScrollback(ctx, owner, id)
+ const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
+ if (timedOut !== undefined) {
+ const partial = renderCaptured(
+ partialOutput(snapshot, marker, fallback),
+ config.maxOutputChars,
+ )
+ await shells.reset(owner, 'persistent bash command timed out')
+ return [
+ `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')
+ }
+ const complete = commandOutput(snapshot, marker)
+ if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
+ if (result.sessionStatus.kind === 'exited') {
+ await shells.reset(owner, 'persistent bash shell exited')
+ return [
+ renderCaptured(partialOutput(snapshot, marker, fallback), config.maxOutputChars),
+ SHELL_RESET_MESSAGE,
+ ].filter(part => part.length > 0).join('\n')
+ }
+ if (commandDeadline.signal.aborted) {
+ await shells.reset(owner, 'persistent bash command aborted')
+ commandDeadline.signal.throwIfAborted()
+ }
+ if (promptCompleted(result)) {
+ return maybeTruncate(stripPrompt(fallback), config.maxOutputChars, result.truncated)
+ }
+ await pause()
+ }
+}
+
+/**
+ * Register the model-facing persistent `bash` tool.
+ * @param ctx - plugin context carrying tools and the owner-scoped PTY service.
+ * @param config - selected PTY backend and command deadline.
+ */
+function registerPersistentBash(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: 'bash',
+ description: config.description,
+ parameters: {
+ command: {
+ type: 'string',
+ required: true,
+ description: 'The bash 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('bash 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-bash-persistent'
+export const inject = ['tools', 'pty']
+
+/** Configuration for the persistent Bash 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 Bash 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 `bash` 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-bash-persistent: backendType must be non-empty')
+ }
+ if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) {
+ throw new Error('tool-bash-persistent: timeoutMs must be a positive safe integer')
+ }
+ if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
+ throw new Error('tool-bash-persistent: maxOutputChars must be a positive safe integer')
+ }
+ if (resolved.description.trim().length === 0) {
+ throw new Error('tool-bash-persistent: description must be non-empty')
+ }
+ registerPersistentBash(ctx, resolved)
+}
diff --git a/packages/pty/tool-bash-persistent/src/invariant.ts b/packages/pty/tool-bash-persistent/src/invariant.ts
new file mode 100644
index 0000000000..f6b5acfbc7
--- /dev/null
+++ b/packages/pty/tool-bash-persistent/src/invariant.ts
@@ -0,0 +1,30 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash-persistent`.
+ * @module @deepseek-ai/dsh-tool-bash-persistent/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash-persistent'
+
+/** Cordis companion plugin name. */
+export const name = 'tool-bash-persistent-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the tool adapter owns no independent durable state;
+ * PTY ownership and filesystem mutation relations stay with their services.
+ */
+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-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts
new file mode 100644
index 0000000000..b8586161f2
--- /dev/null
+++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts
@@ -0,0 +1,156 @@
+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 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import Include from '@cordisjs/plugin-include'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import AgentRegistry 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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
+
+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: [], runnerFailureSignatures: [] }
+ }
+}
+
+function agent(ctx: Context, cwd: string): Agent {
+ const id = SessionId('persistent-bash-loader-agent')
+ const scope = ctx.plugin(() => {})
+ const value: Agent = {
+ id,
+ options: {},
+ session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }),
+ status: 'idle',
+ acceptsNextStep: false,
+ ctx: scope.ctx,
+ followup: () => {},
+ steer: () => {},
+ inject: () => {},
+ send: () => {},
+ cancel() {},
+ 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('')
+}
+
+const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip
+
+suite('persistent Bash through a real cordis.yml Loader composition', () => {
+ it('preserves cwd and environment across calls', async () => {
+ root = await mkdtemp(join(tmpdir(), 'dsh-persistent-bash-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-pty-local'",
+ ' config:',
+ ' pollIntervalMs: 10',
+ ' exactProbeAfterMs: 20',
+ ' idleSilenceMs: 100',
+ ' handoffGraceMs: 100',
+ ' scrollbackLines: 20000',
+ ' timeoutMs: 2000',
+ ' disposeGraceMs: 500',
+ "- name: '@deepseek-ai/dsh-tool-bash-persistent'",
+ ' config:',
+ ' timeoutMs: 5000',
+ '',
+ ].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-pty-local', PtyLocal],
+ ['@deepseek-ai/dsh-tool-bash-persistent', ToolBashPersistent],
+ ])
+ 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: 'bash',
+ arguments: { command },
+ agent: owner,
+ })
+
+ expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash'])
+ await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested')
+ const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"'))
+ expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`)
+ expect(observed).not.toContain('DSH_PERSISTENT_BASH')
+
+ const multiline = text(await execute(
+ 'multiline',
+ 'value="line one"\nprintf "%s:%s\\n" "$value" "it\'s fine"',
+ ))
+ expect(multiline).toBe("line one:it's fine")
+ expect(multiline).not.toContain('DSH_PERSISTENT_BASH')
+
+ const heredoc = text(await execute(
+ 'heredoc',
+ "cat <<'EOF'\nalpha\nbeta\nEOF",
+ ))
+ expect(heredoc).toBe('alpha\nbeta')
+
+ const large = text(await execute('large-output', 'seq 1 12050'))
+ 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 bash call starts from the workspace')
+ expect(text(await execute('after-exit', 'printf "%s\\n" "$PWD"'))).toBe(root)
+ }, 20_000)
+})
diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts
new file mode 100644
index 0000000000..f757d6d1f8
--- /dev/null
+++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts
@@ -0,0 +1,385 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import AgentRegistry 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 ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-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-bash-owner-${callNumber}`)
+ const scope = ctx.plugin(() => {})
+ const value: Agent = {
+ id,
+ options: {},
+ session: new Session(id, [], {
+ version: 0,
+ id,
+ createdAt: 0,
+ ...cwd === undefined ? {} : { cwd },
+ }),
+ status: 'idle',
+ acceptsNextStep: false,
+ ctx: scope.ctx,
+ followup: () => {},
+ steer: () => {},
+ inject: () => {},
+ send: () => {},
+ cancel() {},
+ 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-bash-${++callNumber}`),
+ name: 'bash',
+ arguments: { command },
+ ...owner === undefined ? {} : { agent: owner },
+ })
+}
+
+type StubMode =
+ | 'normal'
+ | 'prompt-only'
+ | 'prompt-crlf'
+ | 'empty-read'
+ | 'stalled-read'
+ | 'exit'
+ | 'wait-for-abort'
+ | 'idle-then-normal'
+ | 'large'
+ | 'end-only'
+ | 'init-exit'
+ | 'init-timeout'
+ | 'spawn-error'
+
+class StubPtySession implements PtyBackendSession {
+ readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
+ readonly pid = 123
+ statusValue: PtySessionStatus = { kind: 'running' }
+ scrollback = this.motd
+ closed: string[] = []
+ mode: StubMode
+ sends = 0
+ pendingText = ''
+
+ constructor(mode: StubMode) {
+ this.mode = mode
+ }
+
+ startSend(request: PtySendRequest): PtySendOperation {
+ this.sends += 1
+ if (request.text.startsWith('stty -echo')) {
+ 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 === 'wait-for-abort') {
+ const done = new Promise>((resolve) => {
+ request.signal?.addEventListener('abort', () => {
+ this.scrollback += 'partial output'
+ resolve(this.result('partial 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-only' || this.mode === 'prompt-crlf') {
+ const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n'
+ const output = `bash: 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 = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0]
+ const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0]
+ 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) : 'hello from stub'
+ const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}`
+ this.scrollback += output
+ if (this.mode === 'exit') {
+ const exitedOutput = `${start ?? ''}\nhello from stub\n`
+ this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput
+ this.statusValue = { kind: 'exited', exitCode: 0, 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 }
+ }
+ const lines = this.scrollback.split('\n')
+ return {
+ text: this.scrollback,
+ totalLines: lines.length,
+ lineBegin: 0,
+ lineEnd: lines.length,
+ truncated: false,
+ }
+ }
+
+ 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>): 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: ToolBashPersistent.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(ToolBashPersistent, config)
+ return { ctx, stub, fiber, owner: agent(ctx, '/workspace') }
+}
+
+describe('tool-bash-persistent', () => {
+ it('registers a configurable schema and reuses one owner shell', async () => {
+ const { ctx, owner, stub } = await setup({
+ backendType: 'stub',
+ description: 'deployment-specific persistent shell',
+ })
+ const schema = ctx.tools.schemas()[0]
+ expect(ctx.tools.schemas().map(item => item.name)).toEqual(['bash'])
+ expect(schema?.description).toBe('deployment-specific persistent shell')
+ expect(schema?.parameters).toMatchObject({
+ required: ['command'],
+ properties: { command: { type: 'string' } },
+ })
+ expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd' }))
+ .toEqual({ card: 'terminal', title: 'pwd' })
+
+ expect(text(await call(ctx, owner, 'echo one'))).toBe('hello from stub')
+ expect(text(await call(ctx, owner, 'echo 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)
+ })
+
+ 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 = 'prompt-only'
+ const promptFallback = text(await call(ctx, owner, 'bad {'))
+ expect(promptFallback).toContain('bash: synt')
+ expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
+
+ session.mode = 'prompt-crlf'
+ session.scrollback = ''
+ const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
+ expect(crlfPromptFallback).toContain('bash: synt')
+ expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_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 = 'exit'
+ const exited = text(await call(ctx, owner, 'exit'))
+ expect(exited).toContain('hello from')
+ expect(exited).toContain('next bash call starts from the workspace')
+ expect(session.closed).toContain('persistent bash shell exited')
+
+ await call(ctx, owner, 'new shell')
+ expect(stub.sessions).toHaveLength(2)
+ 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[1]?.closed).toEqual(['external cleanup'])
+ })
+
+ 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')
+ })
+
+ 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 bash call starts from the workspace')
+ expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out')
+ })
+
+ it('cancels in-flight work, resets the shell, and releases a queued call', async () => {
+ const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 })
+ await call(ctx, owner, 'warm up')
+ stub.sessions[0]!.mode = 'wait-for-abort'
+ 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 bash 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 bash 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('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(() => {
+ ToolBashPersistent.apply(new Context(), { backendType: '' })
+ }).toThrow('backendType must be non-empty')
+ expect(() => {
+ ToolBashPersistent.apply(new Context(), { timeoutMs: 0 })
+ }).toThrow('timeoutMs must be a positive safe integer')
+ expect(() => {
+ ToolBashPersistent.apply(new Context(), { maxOutputChars: 0 })
+ }).toThrow('maxOutputChars must be a positive safe integer')
+ expect(() => {
+ ToolBashPersistent.apply(new Context(), { description: ' ' })
+ }).toThrow('description must be non-empty')
+ })
+})
diff --git a/packages/pty/tool-bash-persistent/tsconfig.json b/packages/pty/tool-bash-persistent/tsconfig.json
new file mode 100644
index 0000000000..b1baf9db9a
--- /dev/null
+++ b/packages/pty/tool-bash-persistent/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": ["src"],
+ "references": [
+ { "path": "../../../vendor/cordis" },
+ { "path": "../../core/agent" },
+ { "path": "../../core/tools" },
+ { "path": "../pty" },
+ { "path": "../../support/invariants" },
+ { "path": "../../util/timeout" }
+ ]
+}
diff --git a/patches/node-pty@1.1.0.patch b/patches/node-pty@1.1.0.patch
new file mode 100644
index 0000000000..f0de7b9054
--- /dev/null
+++ b/patches/node-pty@1.1.0.patch
@@ -0,0 +1,60 @@
+diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
+index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf988cf94c5 100644
+--- a/lib/unixTerminal.js
++++ b/lib/unixTerminal.js
+@@ -26,10 +26,22 @@ var terminal_1 = require("./terminal");
+ var utils_1 = require("./utils");
+ var native = utils_1.loadNativeModule('pty');
+ var pty = native.module;
+-var helperPath = native.dir + '/spawn-helper';
+-helperPath = path.resolve(__dirname, helperPath);
+-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
+-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
++var helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER;
++if (helperPath) {
++ helperPath = path.resolve(helperPath);
++}
++else {
++ var executableSibling = process.execPath + '-spawn-helper';
++ if (fs.existsSync(executableSibling)) {
++ helperPath = executableSibling;
++ }
++ else {
++ helperPath = native.dir + '/spawn-helper';
++ helperPath = path.resolve(__dirname, helperPath);
++ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
++ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
++ }
++}
+ var DEFAULT_FILE = 'sh';
+ var DEFAULT_NAME = 'xterm';
+ var DESTROY_SOCKET_TIMEOUT_MS = 200;
+diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts
+index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf9771220392d17 100644
+--- a/src/unixTerminal.ts
++++ b/src/unixTerminal.ts
+@@ -14,10 +14,20 @@ import { assign, loadNativeModule } from './utils';
+
+ const native = loadNativeModule('pty');
+ const pty: IUnixNative = native.module;
+-let helperPath = native.dir + '/spawn-helper';
+-helperPath = path.resolve(__dirname, helperPath);
+-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
+-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
++let helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER;
++if (helperPath) {
++ helperPath = path.resolve(helperPath);
++} else {
++ const executableSibling = process.execPath + '-spawn-helper';
++ if (fs.existsSync(executableSibling)) {
++ helperPath = executableSibling;
++ } else {
++ helperPath = native.dir + '/spawn-helper';
++ helperPath = path.resolve(__dirname, helperPath);
++ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
++ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
++ }
++}
+
+ const DEFAULT_FILE = 'sh';
+ const DEFAULT_NAME = 'xterm';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 18cb1ec0d1..0fc60b40cb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,6 +6,7 @@ settings:
patchedDependencies:
'@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946
+ node-pty@1.1.0: fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985
importers:
@@ -631,7 +632,7 @@ importers:
devDependencies:
node-pty:
specifier: 1.1.0
- version: 1.1.0
+ version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985)
packages/acp/acp:
dependencies:
@@ -2535,6 +2536,40 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+ packages/fs/tool-str-replace-editor:
+ dependencies:
+ schemastery:
+ specifier: ^3.18.0
+ version: 3.18.0
+ devDependencies:
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-fs':
+ specifier: workspace:^
+ version: link:../fs
+ '@deepseek-ai/dsh-fs-local':
+ specifier: workspace:^
+ version: link:../fs-local
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../support/invariants
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ '@deepseek-ai/dsh-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/goal/command-goal:
devDependencies:
'@cordisjs/plugin-loader':
@@ -3307,7 +3342,7 @@ importers:
dependencies:
node-pty:
specifier: ^1.1.0
- version: 1.1.0
+ version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985)
schemastery:
specifier: ^3.18.0
version: 3.18.0
@@ -3337,6 +3372,55 @@ 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/pty/tool-bash-persistent:
+ dependencies:
+ schemastery:
+ specifier: ^3.18.0
+ version: 3.18.0
+ devDependencies:
+ '@cordisjs/plugin-include':
+ specifier: workspace:^
+ version: link:../../../vendor/include
+ '@cordisjs/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-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-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
+ cordis:
+ specifier: ^4.0.0-rc.7
+ version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
+
packages/pty/tool-pty:
dependencies:
schemastery:
@@ -5520,6 +5604,12 @@ importers:
'@deepseek-ai/dsh-plan-mode':
specifier: workspace:^
version: link:../../packages/plan/plan-mode
+ '@deepseek-ai/dsh-pty':
+ specifier: workspace:^
+ version: link:../../packages/pty/pty
+ '@deepseek-ai/dsh-pty-local':
+ specifier: workspace:^
+ version: link:../../packages/pty/pty-local
'@deepseek-ai/dsh-repeat-tool-guard':
specifier: workspace:^
version: link:../../packages/guard/repeat-tool-guard
@@ -5529,6 +5619,9 @@ importers:
'@deepseek-ai/dsh-sandbox':
specifier: workspace:^
version: link:../../packages/sandbox/sandbox
+ '@deepseek-ai/dsh-sandbox-local':
+ specifier: workspace:^
+ version: link:../../packages/sandbox/sandbox-local
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:^
version: link:../../packages/sandbox/sandbox-policy
@@ -5619,6 +5712,9 @@ importers:
'@deepseek-ai/dsh-tool-bash':
specifier: workspace:^
version: link:../../packages/bash/tool-bash
+ '@deepseek-ai/dsh-tool-bash-persistent':
+ specifier: workspace:^
+ version: link:../../packages/pty/tool-bash-persistent
'@deepseek-ai/dsh-tool-cordis':
specifier: workspace:^
version: link:../../packages/cordis/tool-cordis
@@ -5631,6 +5727,9 @@ importers:
'@deepseek-ai/dsh-tool-skill':
specifier: workspace:^
version: link:../../packages/skill/tool-skill
+ '@deepseek-ai/dsh-tool-str-replace-editor':
+ specifier: workspace:^
+ version: link:../../packages/fs/tool-str-replace-editor
'@deepseek-ai/dsh-tool-subagent':
specifier: workspace:^
version: link:../../packages/subagent/tool-subagent
@@ -15119,7 +15218,7 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
- node-pty@1.1.0:
+ node-pty@1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985):
dependencies:
node-addon-api: 7.1.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 40716f3403..8aad3a1f3d 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -39,6 +39,9 @@ allowBuilds:
node-addon-require-builtin: false
# JSONL durability calls MoveFileExW with write-through publication on Windows.
koffi: true
+ # The Python runtime deploy includes the reviewed workspace postinstall that
+ # places node-pty's spawn helper beside the compiled PTY backend.
+ '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true
# The Landlock launcher family is our own sibling-repo release, consumed
# fresh (hours old at each coordinated bump) — the release-age quarantine
@@ -58,3 +61,4 @@ minimumReleaseAgeExclude:
patchedDependencies:
'@earendil-works/pi-tui@0.80.7': patches/@earendil-works__pi-tui@0.80.7.patch
+ node-pty@1.1.0: patches/node-pty@1.1.0.patch
diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml
index 5df1ca291d..f0d6c67967 100644
--- a/python/README.i18n.yaml
+++ b/python/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: d2b6a1cfe9897026d567b2def301799069c350fb
-README.zh.md: 2ffccef922d52923f7c6e373a01ed8b19d9a55c1
+# pnpm run verify-translation-pairing --write python/README.md
+README.md: aee682e25fc33287c49131d0f5b92b136ed16bae
+README.zh.md: 4404114fcdab78468991769a4657370f85997a88
diff --git a/python/README.md b/python/README.md
index d2b6a1cfe9..aee682e25f 100644
--- a/python/README.md
+++ b/python/README.md
@@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac
pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64
```
-Products land in `dist-exe/` and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the executable with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same binaries. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below).
+Products land in `dist-exe/` and are synced into this package as `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` plus the matching `-spawn-helper` required by `node-pty` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the runtime with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same products. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below).
## Validating the SDK against the executable
diff --git a/python/README.zh.md b/python/README.zh.md
index 2ffccef922..4404114fcd 100644
--- a/python/README.zh.md
+++ b/python/README.zh.md
@@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac
pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64
```
-产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到可执行文件。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的二进制。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。
+产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` 及 `node-pty` 所需的同名 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到运行时。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的产物。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。
## 用可执行文件验证 SDK
diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml
index 90433c4f5f..44fcf94b60 100644
--- a/python/sdk-runtime/README.i18n.yaml
+++ b/python/sdk-runtime/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: f2ccd8939e497d10359aafe8b1bd8b364875ed98
-README.zh.md: 30bdf46fee03c38a1f4b6e8b2b39d87e8174a3e0
+# pnpm run verify-translation-pairing --write python/sdk-runtime/README.md
+README.md: 977bce41191d6c7716548dafde81d2c7ec14dec7
+README.zh.md: ade8455c56c27fcbe3e43a68abeaad98421cf720
diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md
index f2ccd8939e..977bce4119 100644
--- a/python/sdk-runtime/README.md
+++ b/python/sdk-runtime/README.md
@@ -8,19 +8,19 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`,
Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored:
-- **exe (production)** — single-file executables `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). No Node installation needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists.
+- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built and validated as one runtime product. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists.
- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions.
Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding.
A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers.
-Each wheel contains exactly one executable. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple executables, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it.
+Each wheel contains exactly one runtime executable and its matching native spawn helper. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it.
## Resolution API
- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build.
-- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; the node carrier has no single-path equivalent and launches via the argv tuple above).
+- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; it validates that the required sibling `-spawn-helper` is also installed). The node carrier has no single-path equivalent and launches via the argv tuple above.
- `bundled_default_config_path() -> Path` — the checked-in default config (see below).
- `bundled_package_dir() -> Path` — the installed package data root.
diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md
index 30bdf46fee..ade8455c56 100644
--- a/python/sdk-runtime/README.zh.md
+++ b/python/sdk-runtime/README.zh.md
@@ -8,19 +8,19 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,
两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略:
-- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。
+- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;构建与校验会把两者视作同一个运行时产物。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。
- **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。
两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。
exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。
-每个 wheel 包只包含一个可执行文件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。
+每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。
## 解析 API
- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,`node` 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 `node` 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。
-- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体;`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动)。
+- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体,并会校验必要的 `-spawn-helper` 伴随文件也已安装)。`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动。
- `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。
- `bundled_package_dir() -> Path`——已安装包的数据根目录。
diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py
index 1c5b22e11a..108e77cf2c 100644
--- a/python/sdk-runtime/hatch_build.py
+++ b/python/sdk-runtime/hatch_build.py
@@ -13,6 +13,7 @@ _PLATFORMS = {
"linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"),
"macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"),
}
+_SPAWN_HELPER_SUFFIX = "-spawn-helper"
def _host_platform_tag() -> str:
@@ -46,14 +47,23 @@ class RuntimeBuildHook(BuildHookInterface):
)
expected_executable = matches[0][1]
runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime"
- executables = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else [])
+ runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else [])
+ executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)]
+ helpers = [path for path in runtime_files if path.name.endswith(_SPAWN_HELPER_SUFFIX)]
if [path.name for path in executables] != [expected_executable]:
found = ", ".join(path.name for path in executables) or "none"
raise RuntimeError(
f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}"
)
- if executables[0].stat().st_mode & stat.S_IXUSR == 0:
- raise RuntimeError(f"runtime executable is not executable: {executables[0]}")
+ expected_helper = f"{expected_executable}{_SPAWN_HELPER_SUFFIX}"
+ if [path.name for path in helpers] != [expected_helper]:
+ found = ", ".join(path.name for path in helpers) or "none"
+ raise RuntimeError(
+ f"runtime wheel {platform_tag} must contain only {expected_helper}; found {found}"
+ )
+ for executable in [executables[0], helpers[0]]:
+ if executable.stat().st_mode & stat.S_IXUSR == 0:
+ raise RuntimeError(f"runtime executable is not executable: {executable}")
build_data["pure_python"] = False
build_data["infer_tag"] = False
diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json
index a1d8728d4c..cbec3a4923 100644
--- a/python/sdk-runtime/package.json
+++ b/python/sdk-runtime/package.json
@@ -42,6 +42,8 @@
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
+ "@deepseek-ai/dsh-pty": "workspace:^",
+ "@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
@@ -49,6 +51,7 @@
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
"@deepseek-ai/dsh-retention": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
+ "@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
@@ -75,7 +78,9 @@
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
+ "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
+ "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py
index b38df5a211..9228281ab2 100644
--- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py
+++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py
@@ -3,9 +3,10 @@
Two runtime carriers coexist under ``runtime/``, both injected by the repo's
``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git):
-- **exe (production)**: single-file executables named
+- **exe (production)**: single-file Node executables named
``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in
- {x64, arm64}); the target machine needs no Node installation.
+ {x64, arm64}) plus a sibling ``-spawn-helper`` used by ``node-pty``; the
+ target machine needs no Node installation.
- **node (dev-only)**: the full deploy closure under ``runtime/node/``
(``package.json`` + ``node_modules/``), executed as ``node
runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`` on a
@@ -27,6 +28,7 @@ import sys
from pathlib import Path
PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json"
+SPAWN_HELPER_SUFFIX = "-spawn-helper"
RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE"
@@ -82,6 +84,12 @@ def bundled_runtime_path() -> Path:
f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. "
+ _EXE_ACQUISITION_HINT
)
+ helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}")
+ if not helper.is_file():
+ raise FileNotFoundError(
+ f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. "
+ + _EXE_ACQUISITION_HINT
+ )
return path
@@ -144,6 +152,7 @@ def _node_launch_args() -> tuple[str, str]:
__all__ = [
"PACKAGE_METADATA_FILENAME",
"RUNTIME_MODE_ENV_VAR",
+ "SPAWN_HELPER_SUFFIX",
"bundled_default_config_path",
"bundled_package_dir",
"bundled_runtime_path",
diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py
index 38cb40862d..7b7c8254b1 100644
--- a/python/sdk/tests/test_release_version.py
+++ b/python/sdk/tests/test_release_version.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import runpy
+import stat
from pathlib import Path
from types import SimpleNamespace
@@ -37,3 +38,40 @@ def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="must be stable X.Y.Z"):
build_python_release.repository_version(tmp_path)
+
+
+def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> None:
+ executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64"
+ executable.write_bytes(b"runtime")
+ executable.chmod(0o755)
+ spawn_helper = Path(f"{executable}-spawn-helper")
+ spawn_helper.write_bytes(b"helper")
+ spawn_helper.chmod(0o751)
+ destination = tmp_path / "staging"
+
+ build_python_release.stage_runtime(
+ destination,
+ "1.2.3",
+ executable,
+ executable.name,
+ )
+
+ runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime"
+ assert (runtime_dir / executable.name).read_bytes() == b"runtime"
+ copied_helper = runtime_dir / spawn_helper.name
+ assert copied_helper.read_bytes() == b"helper"
+ assert copied_helper.stat().st_mode & stat.S_IXUSR
+
+
+def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None:
+ executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64"
+ executable.write_bytes(b"runtime")
+ executable.chmod(0o755)
+
+ with pytest.raises(FileNotFoundError, match="spawn helper"):
+ build_python_release.stage_runtime(
+ tmp_path / "staging",
+ "1.2.3",
+ executable,
+ executable.name,
+ )
diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts
index b72f73671e..03c82b25b0 100644
--- a/scripts/build-exe-for-python-sdk.ts
+++ b/scripts/build-exe-for-python-sdk.ts
@@ -8,7 +8,7 @@
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, statSync } from 'node:fs'
-import { copyFile, readFile, rm, writeFile } from 'node:fs/promises'
+import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises'
import { basename, join, resolve, sep } from 'node:path'
import { parseArgs } from 'node:util'
@@ -19,6 +19,7 @@ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg'
/** The app entry inside the deployed closure. */
const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js'
const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg'
+const SPAWN_HELPER_SUFFIX = '-spawn-helper'
/** Default Node major; SEA mode requires at least Node 22. */
const DEFAULT_NODE_RANGE = 'node24'
/** Pinned for reproducible builds. */
@@ -52,6 +53,11 @@ const ARCHES = ['x64', 'arm64'] as const
type Platform = (typeof PLATFORMS)[number]
type Arch = (typeof ARCHES)[number]
+interface RuntimeProduct {
+ executable: string
+ spawnHelper: string
+}
+
function isPlatform(value: string): value is Platform {
return (PLATFORMS as readonly string[]).includes(value)
}
@@ -254,6 +260,8 @@ class SingleExeBuild {
'--config.node-linker=hoisted',
'--config.auto-install-peers=false',
'--config.link-workspace-packages=true',
+ // The production closure intentionally omits patched dev-only packages.
+ '--config.allow-unused-patches=true',
this.staging,
])
if (this.cli.dryRun) {
@@ -287,8 +295,9 @@ class SingleExeBuild {
* @param target - the pkg target triple to build.
* @returns the canonical product path `/dsh-jsonrpc-agent-pkg--`.
*/
- async pack(target: Target): Promise {
+ async pack(target: Target): Promise {
const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
+ const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}`
if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true })
await this.run(`pkg ${target.spec}`, pnpmBin(), [
'dlx',
@@ -303,22 +312,59 @@ class SingleExeBuild {
if (!this.cli.dryRun && !existsSync(product)) {
throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
}
- return product
+ if (this.cli.dryRun) {
+ console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`)
+ } else {
+ const source = this.resolveSpawnHelper(target)
+ await copyFile(source, spawnHelper)
+ await chmod(spawnHelper, statSync(source).mode & 0o777)
+ }
+ return { executable: product, spawnHelper }
+ }
+
+ /**
+ * Resolve the node-pty helper that matches a pkg target.
+ * @param target - the pkg target whose helper must be shipped.
+ * @returns a physical executable outside pkg's virtual snapshot.
+ */
+ private resolveSpawnHelper(target: Target): string {
+ const nodePtyRoot = join(this.staging, 'node_modules', 'node-pty')
+ const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux'
+ const candidates = [
+ join(nodePtyRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'spawn-helper'),
+ ]
+ const hostPlatform = process.platform === 'darwin' ? 'macos' : process.platform
+ const hostArch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined
+ if (target.platform === hostPlatform && target.arch === hostArch) {
+ candidates.push(join(nodePtyRoot, 'build', 'Release', 'spawn-helper'))
+ }
+ const helper = candidates.find(candidate => existsSync(candidate))
+ if (helper === undefined) {
+ throw new Error(
+ `build-exe-for-python-sdk: node-pty spawn-helper for ${target.platform}-${target.arch} is missing; `
+ + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`,
+ )
+ }
+ if (statSync(helper).mode & 0o111) return helper
+ throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`)
}
/**
* Print each product path and, outside dry-run mode, its size.
* @param products - the product paths returned by {@link pack}.
*/
- printProducts(products: string[]): void {
+ printProducts(products: RuntimeProduct[]): void {
console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:')
for (const product of products) {
if (this.cli.dryRun) {
- console.log(` ${product}`)
+ console.log(` ${product.executable}`)
+ console.log(` ${product.spawnHelper}`)
continue
}
- const megabytes = statSync(product).size / (1024 * 1024)
- console.log(` ${product} (${megabytes.toFixed(1)} MB)`)
+ for (const path of [product.executable, product.spawnHelper]) {
+ const megabytes = statSync(path).size / (1024 * 1024)
+ console.log(` ${path} (${megabytes.toFixed(1)} MB)`)
+ }
}
}
@@ -327,19 +373,24 @@ class SingleExeBuild {
* carrier is already in place, and `dist-exe/` retains upload copies.
* @param products - the product paths returned by {@link pack}.
*/
- async syncToPythonRuntime(products: string[]): Promise {
+ async syncToPythonRuntime(products: RuntimeProduct[]): Promise {
const destDir = resolve(root, PYTHON_RUNTIME_DIR)
if (this.cli.dryRun) {
for (const product of products) {
- console.log(`build-exe-for-python-sdk: [dry-run] cp ${product} ${join(destDir, basename(product))}`)
+ for (const path of [product.executable, product.spawnHelper]) {
+ console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`)
+ }
}
return
}
mkdirSync(destDir, { recursive: true })
for (const product of products) {
- const destination = join(destDir, basename(product))
- await copyFile(product, destination)
- console.log(`build-exe-for-python-sdk: synced ${destination}`)
+ for (const path of [product.executable, product.spawnHelper]) {
+ const destination = join(destDir, basename(path))
+ await copyFile(path, destination)
+ await chmod(destination, statSync(path).mode & 0o777)
+ console.log(`build-exe-for-python-sdk: synced ${destination}`)
+ }
}
}
@@ -358,7 +409,12 @@ class SingleExeBuild {
}
console.log(`build-exe-for-python-sdk: ${label}: ${printable}`)
await new Promise((resolvePromise, reject) => {
- const child = spawn(command, args, { cwd: root, stdio: 'inherit' })
+ const child = spawn(command, args, {
+ cwd: root,
+ stdio: 'inherit',
+ // Artifact builds must not mutate or validate a developer's Git hooks.
+ env: { ...process.env, CI: 'true' },
+ })
child.once('error', (error) => {
reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`))
})
@@ -383,7 +439,7 @@ async function main(): Promise {
await pipeline.build()
await pipeline.deployStaging()
await pipeline.injectPkgConfig()
- const products: string[] = []
+ const products: RuntimeProduct[] = []
for (const target of cli.targets) products.push(await pipeline.pack(target))
pipeline.printProducts(products)
await pipeline.syncToPythonRuntime(products)
diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py
index e0e90aa818..915968b30d 100644
--- a/scripts/build-python-release.py
+++ b/scripts/build-python-release.py
@@ -22,6 +22,7 @@ PLATFORMS = {
"linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"),
"macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"),
}
+SPAWN_HELPER_SUFFIX = "-spawn-helper"
def main() -> None:
@@ -136,6 +137,11 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_
raise FileNotFoundError(f"runtime executable does not exist: {executable}")
if executable.stat().st_mode & stat.S_IXUSR == 0:
raise PermissionError(f"runtime executable is not executable: {executable}")
+ spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}")
+ if not spawn_helper.is_file():
+ raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}")
+ if spawn_helper.stat().st_mode & stat.S_IXUSR == 0:
+ raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}")
copy_package(ROOT / "python" / "sdk-runtime", destination)
rewrite_version(destination / "pyproject.toml", version)
runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime"
@@ -143,6 +149,9 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_
destination_executable = runtime_dir / executable_name
shutil.copyfile(executable, destination_executable)
destination_executable.chmod(executable.stat().st_mode & 0o777)
+ destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}"
+ shutil.copyfile(spawn_helper, destination_helper)
+ destination_helper.chmod(spawn_helper.stat().st_mode & 0o777)
def verify_wheel(
@@ -161,16 +170,24 @@ def verify_wheel(
raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}")
if metadata.get("Version") != version:
raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}")
- executables = [name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name]
+ runtime_files = [
+ name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name
+ ]
+ helpers = [name for name in runtime_files if name.endswith(SPAWN_HELPER_SUFFIX)]
+ executables = [name for name in runtime_files if not name.endswith(SPAWN_HELPER_SUFFIX)]
if package == "runtime":
assert platform is not None
if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"):
raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}")
- mode = archive.getinfo(executables[0]).external_attr >> 16
- if mode & stat.S_IXUSR == 0:
- raise RuntimeError(f"{wheel} runtime executable lost its executable bit")
- elif executables:
- raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {executables}")
+ expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}"
+ if len(helpers) != 1 or not helpers[0].endswith(f"/runtime/{expected_helper}"):
+ raise RuntimeError(f"{wheel} must contain exactly {expected_helper}, found {helpers}")
+ for executable in [executables[0], helpers[0]]:
+ mode = archive.getinfo(executable).external_attr >> 16
+ if mode & stat.S_IXUSR == 0:
+ raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}")
+ elif runtime_files:
+ raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}")
if package == "sdk":
requirements = metadata.get_all("Requires-Dist") or []
expected_requirement = f"deepseek-harness-runtime-bin=={version}"
diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts
index 7bdc8b68a8..dab261d11e 100644
--- a/scripts/gen-tool-catalog.ts
+++ b/scripts/gen-tool-catalog.ts
@@ -33,9 +33,11 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
+import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-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'
+import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
import PtyService from '@deepseek-ai/dsh-pty'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
import * as ToolGoal from '@deepseek-ai/dsh-tool-goal'
@@ -217,6 +219,32 @@ const TOOL_PACKAGES: ToolPackage[] = [
note:
'Ships in examples/cordis-agent only (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.',
},
+ {
+ pkg: '@deepseek-ai/dsh-tool-bash-persistent',
+ dir: 'tool-bash-persistent',
+ source: 'packages/pty/tool-bash-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(ToolBashPersistent)
+ },
+ 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-str-replace-editor',
+ dir: 'tool-str-replace-editor',
+ source: 'packages/fs/tool-str-replace-editor/src/index.ts',
+ requires: ['ctx.tools', 'ctx.fs'],
+ writes: ['tool/call', 'fs/observed after successful file operations', 'tool/result'],
+ async mount(ctx) {
+ await ctx.plugin(LocalFileSystem)
+ await ctx.plugin(ToolStrReplaceEditor)
+ },
+ note:
+ 'Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface.',
+ },
{
pkg: '@deepseek-ai/dsh-tool-fs',
dir: 'tool-fs',
diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py
index 0019654fdc..11500b5ec7 100644
--- a/scripts/smoke-python-runtime.py
+++ b/scripts/smoke-python-runtime.py
@@ -25,6 +25,14 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value."
CODE_WORKER_TEXT = "code worker smoke ok"
WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents."
WORKFLOW_WORKER_TEXT = "workflow worker smoke ok"
+PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor."
+PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok"
+PERSISTENT_EDITOR_PATH: str | None = None
+PERSISTENT_BASH_COMMAND = (
+ "counter=$(( ${counter:-0} + 1 )); export counter; "
+ "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; "
+ "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi"
+)
SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario."
SNAPSHOT_SESSION_ID = "advanced-executable"
SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else."
@@ -96,6 +104,49 @@ CUSTOM_CORDIS = """\
- id: cordis-tool
name: '@deepseek-ai/dsh-tool-cordis'
"""
+PERSISTENT_TOOLS_CORDIS = """\
+- id: jsonrpc
+ name: '@deepseek-ai/dsh-jsonrpc'
+- id: llm
+ name: '@deepseek-ai/dsh-llm-deepseek'
+ config:
+ apiKey: !!js process.env.DEEPSEEK_API_KEY
+ baseURL: !!js process.env.DEEPSEEK_BASE_URL
+- id: sandbox
+ name: '@deepseek-ai/dsh-sandbox-local'
+- id: sandbox-policy
+ name: '@deepseek-ai/dsh-sandbox-policy'
+ config:
+ mode: danger-full-access
+ workspaceRoot: !!js process.env.DSH_CWD
+- id: pty
+ name: '@deepseek-ai/dsh-pty'
+- id: pty-local
+ name: '@deepseek-ai/dsh-pty-local'
+- id: fs
+ name: '@deepseek-ai/dsh-fs-local'
+ config:
+ cwd: !!js process.env.DSH_CWD
+- id: agent-core
+ name: '@deepseek-ai/dsh-agent-spine-demo'
+ config:
+ includeHarnessIdentity: false
+ persona: 'You are a helpful software engineer assistant.'
+ workspaceContext: false
+ skills:
+ enabled: false
+ toolBash: false
+ toolTasks: false
+- id: sessions
+ name: '@deepseek-ai/dsh-session-persistence-jsonl'
+ config:
+ root: !!js process.env.DSH_SESSION_ROOT
+ compression: 'none'
+- id: persistent-bash
+ name: '@deepseek-ai/dsh-tool-bash-persistent'
+- id: str-replace-editor
+ name: '@deepseek-ai/dsh-tool-str-replace-editor'
+"""
class MockModelHandler(BaseHTTPRequestHandler):
@@ -132,6 +183,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
if latest.get("role") == "tool":
call_id, tool_name = latest_tool_call(messages)
tool_text = message_text(latest.get("content"))
+ persistent = persistent_tool_followup(body, call_id, tool_name, tool_text)
+ if persistent is not None:
+ return persistent
advanced = advanced_tool_followup(body, call_id, tool_name, tool_text)
if advanced is not None:
return advanced
@@ -144,6 +198,15 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
raise AssertionError(f"unexpected tool follow-up: {tool_name}")
prompt = message_text(latest.get("content"))
+ if prompt == PERSISTENT_TOOLS_PROMPT:
+ names = advertised_tool_names(body)
+ if names != {"bash", "str_replace_editor"}:
+ raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}")
+ return tool_call_chunks(
+ "persistent-bash-1",
+ "bash",
+ {"command": PERSISTENT_BASH_COMMAND},
+ )
if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT:
return text_chunks("DIRECT_CHILD_OK")
if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT:
@@ -178,6 +241,44 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
return text_chunks(EXPECTED_TEXT)
+def persistent_tool_followup(
+ body: dict[str, object],
+ call_id: str,
+ tool_name: str,
+ tool_text: str,
+) -> list[dict[str, object]] | None:
+ """Verify packaged PTY persistence, then invoke the packaged editor."""
+ if not call_id.startswith("persistent-"):
+ return None
+ if call_id == "persistent-bash-1" and tool_name == "bash":
+ if "COUNT=1" not in tool_text:
+ raise AssertionError(f"first persistent bash call lost its output: {tool_text}")
+ return tool_call_chunks(
+ "persistent-bash-2",
+ "bash",
+ {"command": PERSISTENT_BASH_COMMAND},
+ )
+ if call_id == "persistent-bash-2" and tool_name == "bash":
+ if "COUNT=2 CWD=/tmp" not in tool_text:
+ raise AssertionError(f"persistent bash did not retain state: {tool_text}")
+ if PERSISTENT_EDITOR_PATH is None:
+ raise AssertionError("persistent editor smoke path was not initialized")
+ return tool_call_chunks(
+ "persistent-editor",
+ "str_replace_editor",
+ {
+ "command": "create",
+ "path": PERSISTENT_EDITOR_PATH,
+ "file_text": "created by packaged editor\n",
+ },
+ )
+ if call_id == "persistent-editor" and tool_name == "str_replace_editor":
+ if "New file created successfully" not in tool_text:
+ raise AssertionError(f"packaged editor did not create its file: {tool_text}")
+ return text_chunks(PERSISTENT_TOOLS_TEXT)
+ raise AssertionError(f"unexpected persistent-tools follow-up: {call_id} {tool_name}: {tool_text}")
+
+
def advanced_tool_followup(
body: dict[str, object],
call_id: str,
@@ -357,14 +458,14 @@ def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--scenario",
- choices=("all", "sdk-default", "sdk-custom", "sdk-snapshot", "direct"),
+ choices=("all", "sdk-default", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"),
default="all",
)
parser.add_argument("--exe", type=Path)
parser.add_argument("--update-snapshots", action="store_true")
args = parser.parse_args()
- if args.scenario in {"all", "sdk-custom", "sdk-snapshot", "direct"} and args.exe is None:
- parser.error("--exe is required for custom, snapshot, and direct scenarios")
+ if args.scenario in {"all", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"} and args.exe is None:
+ parser.error("--exe is required for custom, persistent, snapshot, and direct scenarios")
if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}:
parser.error("--update-snapshots requires --scenario sdk-snapshot or all")
if args.exe is not None and not args.exe.is_file():
@@ -376,6 +477,9 @@ def main() -> None:
if args.scenario in {"all", "sdk-custom"}:
assert args.exe is not None
smoke_sdk_custom(model.url, args.exe.resolve())
+ if args.scenario in {"all", "sdk-persistent"}:
+ assert args.exe is not None
+ smoke_sdk_persistent_tools(model.url, args.exe.resolve())
if args.scenario in {"all", "sdk-snapshot"}:
assert args.exe is not None
smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
@@ -439,6 +543,41 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT)
+def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None:
+ """Exercise native PTY state and the editor through the packaged executable."""
+ global PERSISTENT_EDITOR_PATH
+ from deepseek_harness import DeepSeekHarness
+
+ with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary:
+ root = Path(temporary).resolve()
+ PERSISTENT_EDITOR_PATH = str(root / "created.txt")
+ sessions = root / "sessions"
+ cordis = root / "cordis.yml"
+ cordis.write_text(PERSISTENT_TOOLS_CORDIS)
+ with DeepSeekHarness(
+ provider="deepseek",
+ model="smoke-model",
+ cwd=str(root),
+ session_root=str(sessions),
+ cordis=str(cordis),
+ runtime_bin=str(executable),
+ api_key="sk-keyless-smoke",
+ base_url=base_url,
+ request_timeout_seconds=60,
+ ) as harness:
+ result = harness.run(PERSISTENT_TOOLS_PROMPT, session_id="persistent-tools-smoke")
+
+ assert result.status == "ok", result
+ event_text = json.dumps(result.events)
+ if PERSISTENT_TOOLS_TEXT not in event_text:
+ raise AssertionError(f"packaged tools run emitted no final response: {result.events}")
+ created = root / "created.txt"
+ if created.read_text() != "created by packaged editor\n":
+ raise AssertionError(f"packaged editor wrote unexpected content: {created.read_text()!r}")
+ assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp")
+ PERSISTENT_EDITOR_PATH = None
+
+
def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
"""Drive and compare the advanced SDK/executable behavioral snapshot."""
from deepseek_harness import DeepSeekHarness
diff --git a/tsconfig.host.json b/tsconfig.host.json
index e2112b7f6a..254afc9535 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -103,6 +103,7 @@
{ "path": "./packages/bash/bash" },
{ "path": "./packages/pty/pty" },
{ "path": "./packages/pty/pty-local" },
+ { "path": "./packages/pty/tool-bash-persistent" },
{ "path": "./packages/pty/tool-pty" },
{ "path": "./packages/code-runtime/code-runtime" },
{ "path": "./packages/code-runtime/code-runtime-worker" },
@@ -120,6 +121,7 @@
{ "path": "./packages/fs/fs-sandbox" },
{ "path": "./packages/fs/tool-fs" },
{ "path": "./packages/fs/tool-fs-search" },
+ { "path": "./packages/fs/tool-str-replace-editor" },
{ "path": "./packages/compact/compact" },
{ "path": "./packages/compact/compact-basic" },
{ "path": "./packages/compact/compact-tool-result-prune" },
From d5e7212b31c0546ddb17eab466d7932c5e29fc13 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Wed, 29 Jul 2026 14:23:34 +0800
Subject: [PATCH 003/144] fix(build): refresh node-pty patch hash
---
pnpm-lock.yaml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b271ff848b..4c57d106a6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,7 +6,7 @@ settings:
patchedDependencies:
'@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946
- node-pty@1.1.0: fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985
+ node-pty@1.1.0: 4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15
importers:
@@ -650,7 +650,7 @@ importers:
devDependencies:
node-pty:
specifier: 1.1.0
- version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985)
+ version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15)
packages/acp/acp:
dependencies:
@@ -3384,7 +3384,7 @@ importers:
dependencies:
node-pty:
specifier: ^1.1.0
- version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985)
+ version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15)
schemastery:
specifier: ^3.18.0
version: 3.18.0
@@ -15269,7 +15269,7 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
- node-pty@1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985):
+ node-pty@1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15):
dependencies:
node-addon-api: 7.1.1
From dbdfb8d3b7de697a66c449172310868fb69a8f4b Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Wed, 29 Jul 2026 14:29:16 +0800
Subject: [PATCH 004/144] test(tools): include persistent tool schemas
---
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 3754595f56..1ab8bc2730 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', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', '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', 'lsp', 'ralph', 'read', 'run_code', '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 260ea24594613a00460c1e38a0dabf37579d8ba8 Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Wed, 29 Jul 2026 15:21:56 +0800
Subject: [PATCH 005/144] fix(tools): harden persistent tool integrations
---
docs/config-catalog.md | 12 +-
docs/event-producer-consumer.md | 4 +-
docs/module-graph.md | 4 +-
examples/jsonrpc-agent/README.i18n.yaml | 6 +-
examples/jsonrpc-agent/README.md | 13 +
examples/jsonrpc-agent/README.zh.md | 13 +
.../jsonrpc-agent/persistent-tools.cordis.yml | 58 ++++
.../tests/persistent-tools.snapshot.spec.ts | 214 +++++++++++++++
.../persistent-tools/behavior.expected.json | 57 ++++
examples/package.json | 2 +
.../examples/agent-spine-demo/src/index.ts | 6 +-
.../tool-str-replace-editor/README.i18n.yaml | 4 +-
packages/fs/tool-str-replace-editor/README.md | 7 +-
.../fs/tool-str-replace-editor/README.zh.md | 7 +-
.../fs/tool-str-replace-editor/package.json | 6 +
.../fs/tool-str-replace-editor/src/index.ts | 250 +++++++++++++++---
.../tests/tools.spec.ts | 131 ++++++++-
.../fs/tool-str-replace-editor/tsconfig.json | 2 +
.../pty/tool-bash-persistent/src/index.ts | 96 +++++--
.../tool-bash-persistent/tests/tools.spec.ts | 82 +++++-
pnpm-lock.yaml | 18 ++
python/sdk-runtime/README.i18n.yaml | 4 +-
python/sdk-runtime/README.md | 2 +-
python/sdk-runtime/README.zh.md | 2 +-
scripts/build-exe-for-python-sdk.ts | 4 +-
scripts/smoke-python-runtime.py | 35 ++-
26 files changed, 927 insertions(+), 112 deletions(-)
create mode 100644 examples/jsonrpc-agent/persistent-tools.cordis.yml
create mode 100644 examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts
create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 1e3ed7b6ea..0e92e4e06e 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -153,7 +153,11 @@ export interface Config {
sessionTitle?: SessionTitleConfig
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false
- /** Skill registry, local provider, and model-facing consumer config. */
+ /**
+ * Skill registry, local provider, and model-facing consumer config.
+ * Skills use `enabled` because one nested config controls a provider stack;
+ * single model-tool plugins use `Config | false` to disable that one consumer.
+ */
skills?: SkillConfig
/** Model-facing bash tool config, or false when another plugin owns `bash`. */
toolBash?: toolBash.Config | false
@@ -1589,7 +1593,7 @@ export interface Config {
}
```
-Source: [`packages/pty/tool-bash-persistent/src/index.ts:340`](../packages/pty/tool-bash-persistent/src/index.ts)
+Source: [`packages/pty/tool-bash-persistent/src/index.ts:382`](../packages/pty/tool-bash-persistent/src/index.ts)
## `@deepseek-ai/dsh-tool-cordis`
@@ -1762,10 +1766,12 @@ export interface Config {
description?: string
/** Require local absolute paths like the canonical editor contract (default true). */
requireAbsolutePath?: boolean
+ /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */
+ expandTabsOnMutation?: boolean
}
```
-Source: [`packages/fs/tool-str-replace-editor/src/index.ts:373`](../packages/fs/tool-str-replace-editor/src/index.ts)
+Source: [`packages/fs/tool-str-replace-editor/src/index.ts:539`](../packages/fs/tool-str-replace-editor/src/index.ts)
## `@deepseek-ai/dsh-tool-subagent`
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 63741d22a0..ba952ea8bb 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -26,9 +26,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
-| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
+| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
-| `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) |
+| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 0c3b1f6280..ec2fbb2ccc 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -670,6 +670,8 @@ flowchart TD
pkg_tool_fs_search --> pkg_tools
pkg_tool_str_replace_editor --> pkg_fs
pkg_tool_str_replace_editor --> pkg_invariants
+ pkg_tool_str_replace_editor --> pkg_sandbox
+ pkg_tool_str_replace_editor --> pkg_sandbox_policy
pkg_tool_str_replace_editor --> pkg_tools
pkg_tool_skill --> pkg_agent
pkg_tool_skill --> pkg_invariants
@@ -1095,7 +1097,7 @@ flowchart TD
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
-| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
+| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml
index 59c18e0131..1ff308b60f 100644
--- a/examples/jsonrpc-agent/README.i18n.yaml
+++ b/examples/jsonrpc-agent/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: 6ee4e9d824315bde76b7a534679f018df9a6d3e8
-README.zh.md: dc9b6233e7074e7a9b13bf10bcd2f310b0ad7bf3
+# pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md
+README.md: 9a4c715e8988f52b647dbbd6b14a478cc1357d92
+README.zh.md: fa792f9cf6bdad4f32a980f478a131f19de97611
diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md
index 6ee4e9d824..9a4c715e89 100644
--- a/examples/jsonrpc-agent/README.md
+++ b/examples/jsonrpc-agent/README.md
@@ -25,3 +25,16 @@ The surrounding runtime also loads JSONL session persistence and automatic conte
| `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona |
Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js.
+
+## Persistent tools variant
+
+[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) is a minimal runnable variant whose model-facing surface is exactly:
+
+- owner-scoped persistent `bash`
+- `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`
+
+It composes the real local PTY, filesystem intent policy, and session sandbox policy. The keyless behavior snapshot drives the shipped JSON-RPC runtime through both tools and proves that shell cwd/environment survive across calls:
+
+```bash
+pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts
+```
diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md
index dc9b6233e7..fa792f9cf6 100644
--- a/examples/jsonrpc-agent/README.zh.md
+++ b/examples/jsonrpc-agent/README.zh.md
@@ -25,3 +25,16 @@
| `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 |
通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件命名的每个插件;目标机器无需 Node.js。
+
+## 持久工具变体
+
+[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) 是一个最小可运行变体,面向模型的能力严格只有:
+
+- agent 独占、状态持久的 `bash`
+- 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`
+
+它组合真实本地 PTY、文件系统 intent 策略与 session 沙箱策略。无密钥行为快照会通过正式 JSON-RPC runtime 驱动这两个工具,并验证 shell 的 cwd 与环境变量能跨调用保留:
+
+```bash
+pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts
+```
diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml
new file mode 100644
index 0000000000..b5ae81b100
--- /dev/null
+++ b/examples/jsonrpc-agent/persistent-tools.cordis.yml
@@ -0,0 +1,58 @@
+# Minimal unattended composition for the persistent Bash and string-replace
+# editor. It is runnable through the JSON-RPC example runtime and intentionally
+# keeps the model-facing surface to exactly these two tools.
+
+- id: jsonrpc
+ name: '@deepseek-ai/dsh-jsonrpc'
+
+- id: llm-deepseek
+ name: '@deepseek-ai/dsh-llm-deepseek'
+ config:
+ apiKey: !!js process.env.DEEPSEEK_API_KEY
+ baseURL: !!js process.env.DEEPSEEK_BASE_URL
+
+- id: sandbox
+ name: '@deepseek-ai/dsh-sandbox-local'
+
+- id: sandbox-policy
+ name: '@deepseek-ai/dsh-sandbox-policy'
+ config:
+ mode: danger-full-access
+ workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd()
+
+- id: pty
+ name: '@deepseek-ai/dsh-pty'
+
+- id: pty-local
+ name: '@deepseek-ai/dsh-pty-local'
+
+- id: fs-sandbox
+ name: '@deepseek-ai/dsh-fs-sandbox'
+ config:
+ cwd: !!js process.env.DSH_CWD ?? process.cwd()
+
+- id: fs-policy
+ name: '@deepseek-ai/dsh-fs-policy'
+
+- id: agent-spine
+ name: '@deepseek-ai/dsh-agent-spine-demo'
+ config:
+ includeHarnessIdentity: false
+ persona: 'You are a helpful software engineer assistant.'
+ workspaceContext: false
+ skills:
+ enabled: false
+ toolBash: false
+ toolTasks: false
+
+- id: persistent-bash
+ name: '@deepseek-ai/dsh-tool-bash-persistent'
+
+- id: str-replace-editor
+ name: '@deepseek-ai/dsh-tool-str-replace-editor'
+
+- id: sessions
+ name: '@deepseek-ai/dsh-session-persistence-jsonl'
+ config:
+ root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions'
+ compression: none
diff --git a/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts b/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts
new file mode 100644
index 0000000000..18d1454837
--- /dev/null
+++ b/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts
@@ -0,0 +1,214 @@
+import { createServer } from 'node:http'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
+import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
+
+const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
+const configPath = fileURLToPath(new URL('../persistent-tools.cordis.yml', import.meta.url))
+const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
+const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
+const expectedPath = fileURLToPath(new URL('./snapshots/persistent-tools/behavior.expected.json', import.meta.url))
+
+interface ModelRequest {
+ messages?: Array>
+ tools?: Array<{ function?: { name?: string; parameters?: { required?: string[] } } }>
+}
+
+function sseToolCall(id: string, name: string, args: Record): string[] {
+ return [
+ 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n',
+ `data: ${JSON.stringify({
+ choices: [{
+ delta: {
+ tool_calls: [{
+ index: 0,
+ id,
+ type: 'function',
+ function: { name, arguments: JSON.stringify(args) },
+ }],
+ },
+ }],
+ })}\n\n`,
+ 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n',
+ 'data: [DONE]\n\n',
+ ]
+}
+
+function sseText(text: string): string[] {
+ return [
+ 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n',
+ `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`,
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n',
+ 'data: [DONE]\n\n',
+ ]
+}
+
+function messageText(content: unknown): string {
+ if (typeof content === 'string') return content
+ if (!Array.isArray(content)) return ''
+ return content.flatMap((block) => {
+ if (typeof block !== 'object' || block === null) return []
+ const text = (block as { text?: unknown }).text
+ return typeof text === 'string' ? [text] : []
+ }).join('')
+}
+
+function latestToolCall(messages: Array>): { id: string; name: string } {
+ for (const message of messages.toReversed()) {
+ const calls = message.tool_calls
+ if (!Array.isArray(calls)) continue
+ const call = (calls as unknown[]).at(-1)
+ if (typeof call !== 'object' || call === null) continue
+ const id = (call as { id?: unknown }).id
+ const fn = (call as { function?: { name?: unknown } }).function
+ if (typeof id === 'string' && typeof fn?.name === 'string') return { id, name: fn.name }
+ }
+ throw new Error('model request has no preceding tool call')
+}
+
+function normalize(value: string, cwd: string): string {
+ return value.replaceAll(cwd, '{{cwd}}')
+}
+
+describe('jsonrpc persistent tools snapshot', () => {
+ it('runs persistent shell state and editor mutations keylessly', async () => {
+ const cwd = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-persistent-tools-'))
+ const sessionRoot = join(cwd, '.sessions')
+ const target = join(cwd, 'note.txt')
+ const requests: ModelRequest[] = []
+ const modelServer = createServer((request, response) => {
+ let body = ''
+ request.setEncoding('utf8')
+ request.on('data', (chunk: string) => { body += chunk })
+ request.on('end', () => {
+ const parsed = JSON.parse(body) as ModelRequest
+ requests.push(parsed)
+ const messages = parsed.messages ?? []
+ const latest = messages.at(-1)
+ if (latest === undefined) throw new Error('model request has no messages')
+ let chunks: string[]
+ if (latest.role !== 'tool') {
+ chunks = sseToolCall('bash-1', 'bash', {
+ command: 'cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"',
+ })
+ } else {
+ const call = latestToolCall(messages)
+ const toolText = messageText(latest.content)
+ if (call.id === 'bash-1') {
+ expect(toolText).toContain('COUNT=1 CWD=/tmp')
+ chunks = sseToolCall('bash-2', 'bash', {
+ command: 'DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"',
+ })
+ } else if (call.id === 'bash-2') {
+ expect(toolText).toContain('COUNT=2 CWD=/tmp')
+ chunks = sseToolCall('editor-create', 'str_replace_editor', {
+ command: 'create',
+ path: target,
+ file_text: 'alpha\n',
+ })
+ } else if (call.id === 'editor-create') {
+ expect(toolText).toContain('New file created successfully')
+ chunks = sseToolCall('editor-replace', 'str_replace_editor', {
+ command: 'str_replace',
+ path: target,
+ old_str: 'alpha',
+ new_str: 'beta',
+ })
+ } else if (call.id === 'editor-replace') {
+ expect(toolText).toContain('has been edited successfully')
+ chunks = sseText('PERSISTENT_TOOLS_OK')
+ } else {
+ throw new Error(`unexpected tool call ${call.id}`)
+ }
+ }
+ response.writeHead(200, { 'content-type': 'text/event-stream' })
+ for (const chunk of chunks) response.write(chunk)
+ response.end()
+ })
+ })
+ await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve))
+ const address = modelServer.address()
+ if (address === null || typeof address === 'string') throw new Error('model server did not bind')
+ const launch = resolveExampleLaunch({
+ srcBin: runtimeBin,
+ configArgs: [],
+ tsconfigPath: repoTsconfig,
+ })
+ const harness = new DeepSeekHarness({
+ launch: {
+ command: launch.command,
+ args: launch.args,
+ cwd: repoRoot,
+ env: {
+ ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record,
+ ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record,
+ DSH_CORDIS_CONFIG: configPath,
+ DSH_CWD: cwd,
+ DSH_SESSION_ROOT: sessionRoot,
+ DEEPSEEK_API_KEY: 'keyless-local-mock',
+ DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
+ },
+ requestTimeoutMs: 60_000,
+ },
+ cwd,
+ provider: 'deepseek',
+ model: 'deepseek-v4-flash',
+ })
+
+ try {
+ const result = await harness.run(
+ 'Prove that bash state persists, then create and edit note.txt.',
+ { sessionId: 'persistent-tools-snapshot' },
+ )
+ const calls = result.events.flatMap((event) => {
+ if (event.type !== 'tool/call') return []
+ return [{
+ name: event.data.name,
+ arguments: normalize(event.data.arguments, cwd),
+ }]
+ })
+ const results = result.events.flatMap((event) => {
+ if (event.type !== 'tool/result') return []
+ return event.data.message.content.flatMap((block) => {
+ if (block.type !== 'tool-result') return []
+ return block.content.flatMap(content =>
+ content.type === 'text'
+ ? [{ text: normalize(content.text, cwd) }]
+ : [])
+ })
+ })
+ const tools = (requests[0]?.tools ?? []).map(tool => ({
+ name: tool.function?.name,
+ required: tool.function?.parameters?.required ?? [],
+ })).sort((left, right) => {
+ const leftName = String(left.name)
+ const rightName = String(right.name)
+ return leftName < rightName ? -1 : leftName > rightName ? 1 : 0
+ })
+ const behavior = {
+ tools,
+ calls,
+ results,
+ final: {
+ status: result.status,
+ reason: result.reason,
+ response: result.finalResponse,
+ file: await readFile(target, 'utf8'),
+ },
+ }
+ if (process.env.DSH_SNAPSHOT === 'refresh') {
+ await writeFile(expectedPath, `${JSON.stringify(behavior, null, 2)}\n`)
+ }
+ expect(behavior).toEqual(JSON.parse(await readFile(expectedPath, 'utf8')))
+ } finally {
+ await harness.close()
+ await new Promise(resolve => modelServer.close(() => { resolve() }))
+ await rm(cwd, { recursive: true, force: true })
+ }
+ }, 75_000)
+})
diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json
new file mode 100644
index 0000000000..18b54e9312
--- /dev/null
+++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json
@@ -0,0 +1,57 @@
+{
+ "tools": [
+ {
+ "name": "bash",
+ "required": [
+ "command"
+ ]
+ },
+ {
+ "name": "str_replace_editor",
+ "required": [
+ "command",
+ "path"
+ ]
+ }
+ ],
+ "calls": [
+ {
+ "name": "bash",
+ "arguments": "{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"
+ },
+ {
+ "name": "bash",
+ "arguments": "{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"
+ },
+ {
+ "name": "str_replace_editor",
+ "arguments": "{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"
+ },
+ {
+ "name": "str_replace_editor",
+ "arguments": "{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"
+ }
+ ],
+ "results": [
+ {
+ "text": "COUNT=1 CWD=/tmp"
+ },
+ {
+ "text": "COUNT=2 CWD=/tmp"
+ },
+ {
+ "text": "New file created successfully at: {{cwd}}/note.txt"
+ },
+ {
+ "text": "The file {{cwd}}/note.txt has been edited successfully."
+ }
+ ],
+ "final": {
+ "status": "ok",
+ "reason": {
+ "kind": "completed"
+ },
+ "response": "PERSISTENT_TOOLS_OK",
+ "file": "beta\n"
+ }
+}
diff --git a/examples/package.json b/examples/package.json
index 51fc48b8fa..d98371a5ef 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-timeout-policy": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-tool-ask-user": "workspace:*",
+ "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*",
"@deepseek-ai/dsh-tool-cordis": "workspace:*",
"@deepseek-ai/dsh-tool-fs": "workspace:*",
"@deepseek-ai/dsh-tool-fs-search": "workspace:*",
@@ -64,6 +65,7 @@
"@deepseek-ai/dsh-tool-pty": "workspace:*",
"@deepseek-ai/dsh-tool-ralph": "workspace:*",
"@deepseek-ai/dsh-tool-session-query": "workspace:*",
+ "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*",
"@deepseek-ai/dsh-tool-subagent": "workspace:*",
"@deepseek-ai/dsh-tool-todo": "workspace:*",
"@deepseek-ai/dsh-tool-web": "workspace:*",
diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts
index cfa5ac3ccd..32e348bf75 100644
--- a/packages/examples/agent-spine-demo/src/index.ts
+++ b/packages/examples/agent-spine-demo/src/index.ts
@@ -105,7 +105,11 @@ export interface Config {
sessionTitle?: SessionTitleConfig
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false
- /** Skill registry, local provider, and model-facing consumer config. */
+ /**
+ * Skill registry, local provider, and model-facing consumer config.
+ * Skills use `enabled` because one nested config controls a provider stack;
+ * single model-tool plugins use `Config | false` to disable that one consumer.
+ */
skills?: SkillConfig
/** Model-facing bash tool config, or false when another plugin owns `bash`. */
toolBash?: toolBash.Config | false
diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml
index 1f72b1a211..10b1f19182 100644
--- a/packages/fs/tool-str-replace-editor/README.i18n.yaml
+++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-str-replace-editor/README.md
-README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b
-README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d
+README.md: 8ac6a22f24ddcdd3818b346e3426e58e718027e2
+README.zh.md: cf82b132b63730af209c159066a70f6a18b77f39
diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md
index 2d98b51d56..8ac6a22f24 100644
--- a/packages/fs/tool-str-replace-editor/README.md
+++ b/packages/fs/tool-str-replace-editor/README.md
@@ -11,6 +11,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w
| `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. |
| `description` | Editor command guide | Model-facing tool description. |
| `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. |
+| `expandTabsOnMutation` | `true` | Preserve the canonical Claude SWE behavior that expands tabs across the whole file before replace/insert. Set `false` for atomic literal replacement that preserves unrelated tabs. |
## Tool
@@ -36,7 +37,7 @@ Prefix-stable while the configured description and schema remain unchanged.
#### What the model sees
-Views return numbered text or a shallow directory listing. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice.
+Views return numbered text or a shallow directory listing. Calls expose file locations, and create/replace calls expose diff cards to presentation surfaces. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice.
#### Token effect
@@ -50,5 +51,5 @@ Append-only tool results follow the reusable request prefix.
- Operations target UTF-8 text; binary files are unsupported.
- `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument.
-- Canonical mode expands tabs before replacement or insertion, matching the reference string-replacement editor.
-- The package delegates security and read-before-edit policy to the mounted filesystem and policy plugins.
+- Canonical mode (`expandTabsOnMutation: true`) expands tabs in the entire file before replacement or insertion, including lines outside the edited region. Set it to `false` for Makefiles and other tab-sensitive files.
+- Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins.
diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md
index a2ee8f1e36..cf82b132b6 100644
--- a/packages/fs/tool-str-replace-editor/README.zh.md
+++ b/packages/fs/tool-str-replace-editor/README.zh.md
@@ -11,6 +11,7 @@
| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 |
| `description` | 编辑器命令指南 | 面向模型的工具描述。 |
| `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 |
+| `expandTabsOnMutation` | `true` | 保留 Claude SWE 参考行为:替换/插入前展开整个文件的制表符。设为 `false` 时使用原子字面量替换,并保留未触及的制表符。 |
## 工具
@@ -36,7 +37,7 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使
#### 模型所见
-查看操作返回带行号文本或浅层目录列表。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
+查看操作返回带行号文本或浅层目录列表。调用会向展示层提供文件位置,创建/替换还会提供 diff 卡片。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。
#### Token 影响
@@ -50,5 +51,5 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使
- 操作面向 UTF-8 文本,不支持二进制文件。
- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。
-- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。
-- 安全与先读后改策略委托给挂载的文件系统和策略插件。
+- 规范模式(`expandTabsOnMutation: true`)会在替换或插入前展开整个文件中的制表符,包括未编辑区域。Makefile 等依赖制表符的文件应设为 `false`。
+- 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。
diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json
index 88d3af8d53..0fc6e54ec6 100644
--- a/packages/fs/tool-str-replace-editor/package.json
+++ b/packages/fs/tool-str-replace-editor/package.json
@@ -28,6 +28,8 @@
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
+ "@deepseek-ai/dsh-sandbox": "^0.0.1",
+ "@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -38,8 +40,12 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
+ "@deepseek-ai/dsh-fs-policy": "workspace:^",
+ "@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-sandbox": "workspace:^",
+ "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts
index c4a16e5437..5d9855c1da 100644
--- a/packages/fs/tool-str-replace-editor/src/index.ts
+++ b/packages/fs/tool-str-replace-editor/src/index.ts
@@ -7,9 +7,12 @@ import { isAbsolute } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import { FsError } from '@deepseek-ai/dsh-fs'
-import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs'
+import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
+import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
+import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
+import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { defineTool } from '@deepseek-ai/dsh-tools'
-import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
+import type { ToolCallView, ToolRunContext } from '@deepseek-ai/dsh-tools'
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 `grep -n` in order to find the line numbers of what you are looking for. '
@@ -49,17 +52,68 @@ function expandTabs(content: string, tabSize = 8): string {
return result
}
+function codepointCompare(left: string, right: string): number {
+ return left < right ? -1 : left > right ? 1 : 0
+}
+
+function matchOffsets(content: string, search: string): number[] {
+ const offsets: number[] = []
+ let offset = 0
+ while (true) {
+ const match = content.indexOf(search, offset)
+ if (match < 0) return offsets
+ offsets.push(match)
+ offset = match + search.length
+ }
+}
+
+function lineNumbersAt(content: string, offsets: readonly number[]): number[] {
+ let line = 1
+ let cursor = 0
+ return offsets.map((offset) => {
+ while (cursor < offset) {
+ if (content[cursor] === '\n') line += 1
+ cursor += 1
+ }
+ return line
+ })
+}
+
+class MutationPolicy {
+ private readonly policy: SandboxPolicyService | undefined
+
+ constructor(ctx: Context) {
+ this.policy = ctx.fs.sandboxMode === undefined ? undefined : ctx.get('sandboxPolicy')
+ if (ctx.fs.sandboxMode !== undefined && this.policy === undefined) {
+ throw new Error('tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing')
+ }
+ }
+
+ resolve(exec: ToolRunContext): SandboxExecutionPolicy | undefined {
+ return this.policy?.resolve({
+ ...exec.agent === undefined ? {} : { session: exec.agent.session },
+ })
+ }
+
+ mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown {
+ if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error
+ const mode = (policy as SandboxExecutionPolicy).mode
+ return new FsError(sandboxDenialMarker(mode), 'FS_SANDBOX_DENIED', { cause: error })
+ }
+}
+
async function resolveTarget(
ctx: Context,
path: string,
requireAbsolutePath: boolean,
exec: ToolRunContext,
+ workspaceRoot?: string,
): Promise {
if (path.trim().length === 0) throw new Error('path must be a non-empty string')
if (requireAbsolutePath && !isAbsolute(path)) {
throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`)
}
- const cwd = exec.agent?.session.header.cwd
+ const cwd = exec.agent?.session.header.cwd ?? workspaceRoot
return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal })
}
@@ -158,8 +212,8 @@ async function listDirectory(
const rows: string[] = []
for (const entry of entries.filter(candidate =>
!candidate.name.startsWith('.')
- && !candidate.name.startsWith('node_modules')
- && !candidate.name.startsWith('__pycache__'))) {
+ && candidate.name !== 'node_modules'
+ && candidate.name !== '__pycache__')) {
const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?'
rows.push(`${type}\t${entry.target.displayPath}`)
if (entry.type === 'directory' && depth < 2) {
@@ -172,7 +226,7 @@ async function listDirectory(
rows.sort((left, right) => {
const leftPath = left.slice(left.indexOf('\t') + 1)
const rightPath = right.slice(right.indexOf('\t') + 1)
- return leftPath.localeCompare(rightPath)
+ return codepointCompare(leftPath, rightPath)
})
const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars)
return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n`
@@ -204,78 +258,124 @@ async function viewPath(
async function createFile(
ctx: Context,
+ policy: MutationPolicy,
path: string,
fileText: string | undefined,
requireAbsolutePath: boolean,
exec: ToolRunContext,
): Promise {
const content = requiredForCommand(fileText, 'file_text', 'create')
- const target = await resolveTarget(ctx, path, requireAbsolutePath, exec)
+ const sandboxPolicy = policy.resolve(exec)
+ const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot)
if (await ctx.fs.stat(target, exec.signal) !== undefined) {
throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`)
}
- const outcome = await ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, exec.signal)
+ const intent = await ctx.waterfall(
+ 'fs/write-intent',
+ target,
+ exec,
+ () => ({ kind: 'createIfAbsent' } as const),
+ )
+ let outcome
+ try {
+ outcome = await ctx.fs.writeText(
+ target,
+ content,
+ intent ?? { kind: 'createIfAbsent' },
+ exec.signal,
+ sandboxPolicy,
+ )
+ } catch (error: unknown) {
+ throw policy.mapError(error, sandboxPolicy)
+ }
ctx.emit('fs/observed', target, outcome.version, exec)
return `New file created successfully at: ${target.displayPath}`
}
async function replaceInFile(
ctx: Context,
+ policy: MutationPolicy,
path: string,
oldStr: string | undefined,
newStr: string | undefined,
requireAbsolutePath: boolean,
+ expandTabsOnMutation: boolean,
exec: ToolRunContext,
): Promise {
- const target = await resolveTarget(ctx, path, requireAbsolutePath, exec)
- const oldValue = expandTabs(requiredForCommand(oldStr, 'old_str', 'str_replace', false))
- const newValue = expandTabs(newStr ?? '')
+ const sandboxPolicy = policy.resolve(exec)
+ const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot)
+ const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
+ const rawOldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false)
+ const oldValue = expandTabsOnMutation ? expandTabs(rawOldValue) : rawOldValue
+ const newValue = expandTabsOnMutation ? expandTabs(newStr ?? '') : newStr ?? ''
const info = await statExisting(ctx, target, 'str_replace', exec)
if (info.type !== 'file') {
throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
- const before = expandTabs(await ctx.fs.readText(target, exec.signal))
- const occurrences = before.split(oldValue).length - 1
- if (occurrences === 0) {
+ const rawBefore = await ctx.fs.readText(target, exec.signal)
+ const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore
+ const offsets = matchOffsets(before, oldValue)
+ if (offsets.length === 0) {
throw new FsError(
`No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`,
'FS_EDIT_NOT_FOUND',
)
}
- if (occurrences > 1) {
- const lines = before.split('\n')
- .flatMap((line, index) => line.includes(oldValue) ? [index + 1] : [])
+ if (offsets.length > 1) {
+ const lines = lineNumbersAt(before, offsets)
throw new FsError(
`No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`,
'FS_AMBIGUOUS_EDIT',
)
}
- const outcome = await ctx.fs.writeText(
- target,
- before.replace(oldValue, newValue),
- { kind: 'replaceIfVersion', version: info.version },
- exec.signal,
- )
+ let outcome
+ try {
+ outcome = expandTabsOnMutation
+ ? await ctx.fs.writeText(
+ target,
+ before.replace(oldValue, newValue),
+ intent === undefined
+ ? { kind: 'replaceIfVersion', version: info.version }
+ : { kind: 'replaceIfVersion', version: intent.version },
+ exec.signal,
+ sandboxPolicy,
+ )
+ : await ctx.fs.editText(
+ target,
+ { oldString: oldValue, newString: newValue, replaceAll: false },
+ intent ?? { version: info.version },
+ exec.signal,
+ sandboxPolicy,
+ )
+ } catch (error: unknown) {
+ throw policy.mapError(error, sandboxPolicy)
+ }
ctx.emit('fs/observed', target, outcome.version, exec)
return `The file ${target.displayPath} has been edited successfully.`
}
async function insertInFile(
ctx: Context,
+ policy: MutationPolicy,
path: string,
insertLine: number | undefined,
newStr: string | undefined,
requireAbsolutePath: boolean,
+ expandTabsOnMutation: boolean,
exec: ToolRunContext,
): Promise {
if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert')
- const value = expandTabs(requiredForCommand(newStr, 'new_str', 'insert'))
- const target = await resolveTarget(ctx, path, requireAbsolutePath, exec)
+ const rawValue = requiredForCommand(newStr, 'new_str', 'insert')
+ const value = expandTabsOnMutation ? expandTabs(rawValue) : rawValue
+ const sandboxPolicy = policy.resolve(exec)
+ const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot)
+ const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
const info = await statExisting(ctx, target, 'insert', exec)
if (info.type !== 'file') {
throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
- const before = expandTabs(await ctx.fs.readText(target, exec.signal))
+ const rawBefore = await ctx.fs.readText(target, exec.signal)
+ const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore
const lines = before.split('\n')
if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) {
throw new Error(
@@ -287,12 +387,15 @@ async function insertInFile(
...value.split('\n'),
...lines.slice(insertLine),
].join('\n')
- const outcome = await ctx.fs.writeText(
- target,
- after,
- { kind: 'replaceIfVersion', version: info.version },
- exec.signal,
- )
+ const expected: FsWriteIntent = intent === undefined
+ ? { kind: 'replaceIfVersion', version: info.version }
+ : { kind: 'replaceIfVersion', version: intent.version }
+ let outcome
+ try {
+ outcome = await ctx.fs.writeText(target, after, expected, exec.signal, sandboxPolicy)
+ } catch (error: unknown) {
+ throw policy.mapError(error, sandboxPolicy)
+ }
ctx.emit('fs/observed', target, outcome.version, exec)
return `The file ${target.displayPath} has been edited successfully.`
}
@@ -301,10 +404,59 @@ interface ResolvedConfig {
maxOutputChars: number
description: string
requireAbsolutePath: boolean
+ expandTabsOnMutation: boolean
+}
+
+function presentEditorCall(args: {
+ command: 'view' | 'create' | 'str_replace' | 'insert'
+ path: string
+ file_text?: string
+ insert_line?: number
+ new_str?: string
+ old_str?: string
+}): ToolCallView {
+ switch (args.command) {
+ case 'view':
+ return {
+ card: 'generic',
+ title: `view ${args.path}`,
+ kind: 'read',
+ locations: [{ path: args.path }],
+ }
+ case 'create':
+ return {
+ card: 'diff',
+ title: `create ${args.path}`,
+ diffs: [{ path: args.path, oldText: null, newText: args.file_text ?? '' }],
+ locations: [{ path: args.path }],
+ }
+ case 'str_replace':
+ return {
+ card: 'diff',
+ title: `str_replace ${args.path}`,
+ diffs: [{
+ path: args.path,
+ oldText: args.old_str ?? null,
+ newText: args.new_str ?? '',
+ }],
+ locations: [{ path: args.path }],
+ }
+ case 'insert':
+ return {
+ card: 'generic',
+ title: `insert ${args.path}`,
+ kind: 'edit',
+ locations: [{
+ path: args.path,
+ ...args.insert_line === undefined ? {} : { line: Math.max(1, args.insert_line + 1) },
+ }],
+ }
+ }
}
/** Register the model-facing `str_replace_editor` tool. */
function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void {
+ const policy = new MutationPolicy(ctx)
ctx.tools.register(defineTool({
name: 'str_replace_editor',
description: config.description,
@@ -351,18 +503,32 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void {
case 'view':
return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec)
case 'create':
- return createFile(ctx, args.path, args.file_text, config.requireAbsolutePath, exec)
+ return createFile(ctx, policy, args.path, args.file_text, config.requireAbsolutePath, exec)
case 'str_replace':
- return replaceInFile(ctx, args.path, args.old_str, args.new_str, config.requireAbsolutePath, exec)
+ return replaceInFile(
+ ctx,
+ policy,
+ args.path,
+ args.old_str,
+ args.new_str,
+ config.requireAbsolutePath,
+ config.expandTabsOnMutation,
+ exec,
+ )
case 'insert':
- return insertInFile(ctx, args.path, args.insert_line, args.new_str, config.requireAbsolutePath, exec)
+ return insertInFile(
+ ctx,
+ policy,
+ args.path,
+ args.insert_line,
+ args.new_str,
+ config.requireAbsolutePath,
+ config.expandTabsOnMutation,
+ exec,
+ )
}
},
- presentCall: args => ({
- card: 'generic',
- title: `${args.command} ${args.path}`,
- kind: args.command === 'view' ? 'read' : 'edit',
- }),
+ presentCall: presentEditorCall,
}))
}
@@ -377,6 +543,8 @@ export interface Config {
description?: string
/** Require local absolute paths like the canonical editor contract (default true). */
requireAbsolutePath?: boolean
+ /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */
+ expandTabsOnMutation?: boolean
}
/** Runtime configuration schema for the string-replacement editor tool. */
@@ -384,6 +552,7 @@ export const Config: z = z.object({
maxOutputChars: z.number().default(16_000),
description: z.string().default(DEFAULT_DESCRIPTION),
requireAbsolutePath: z.boolean().default(true),
+ expandTabsOnMutation: z.boolean().default(true),
})
/** Register one `str_replace_editor` tool over `ctx.fs`. */
@@ -392,6 +561,7 @@ export function apply(ctx: Context, config: Config): void {
maxOutputChars: config.maxOutputChars ?? 16_000,
description: config.description ?? DEFAULT_DESCRIPTION,
requireAbsolutePath: config.requireAbsolutePath ?? true,
+ expandTabsOnMutation: config.expandTabsOnMutation ?? true,
}
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer')
diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts
index 7cf9ba5212..213161e147 100644
--- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts
+++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts
@@ -9,6 +9,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
+import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
+import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
+import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
@@ -57,7 +60,10 @@ function call(ctx: Context, owner: Agent | undefined, args: unknown) {
})
}
-async function setup(config: ToolStrReplaceEditor.Config = {}) {
+async function setup(
+ config: ToolStrReplaceEditor.Config = {},
+ options: { fsPolicy?: boolean; sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {},
+) {
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-'))
roots.push(root)
const ctx = new Context()
@@ -65,7 +71,13 @@ async function setup(config: ToolStrReplaceEditor.Config = {}) {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
- await ctx.plugin(LocalFileSystem, { cwd: root })
+ if (options.sandboxMode === undefined) {
+ await ctx.plugin(LocalFileSystem, { cwd: root })
+ } else {
+ await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root })
+ await ctx.plugin(SandboxedFileSystem, { cwd: root })
+ }
+ if (options.fsPolicy === true) await ctx.plugin(FsPolicy)
await ctx.plugin(ToolStrReplaceEditor, config)
return { ctx, root, owner: agent(ctx, root) }
}
@@ -85,13 +97,38 @@ describe('tool-str-replace-editor', () => {
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'view',
path: '/workspace/a.txt',
- })).toMatchObject({ card: 'generic', kind: 'read' })
+ })).toMatchObject({
+ card: 'generic',
+ kind: 'read',
+ locations: [{ path: '/workspace/a.txt' }],
+ })
+ expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
+ command: 'create',
+ path: '/workspace/a.txt',
+ file_text: 'hello',
+ })).toMatchObject({
+ card: 'diff',
+ diffs: [{ path: '/workspace/a.txt', oldText: null, newText: 'hello' }],
+ })
+ expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
+ command: 'str_replace',
+ path: '/workspace/a.txt',
+ old_str: 'old',
+ new_str: 'new',
+ })).toMatchObject({
+ card: 'diff',
+ diffs: [{ path: '/workspace/a.txt', oldText: 'old', newText: 'new' }],
+ })
expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
command: 'insert',
path: '/workspace/a.txt',
insert_line: 0,
new_str: 'x',
- })).toMatchObject({ card: 'generic', kind: 'edit' })
+ })).toMatchObject({
+ card: 'generic',
+ kind: 'edit',
+ locations: [{ path: '/workspace/a.txt', line: 1 }],
+ })
})
it('creates, views, replaces, and inserts with the canonical model-facing output', async () => {
@@ -136,16 +173,20 @@ describe('tool-str-replace-editor', () => {
})
it('lists visible entries to depth two and clips at the configured view limit', async () => {
- const { ctx, root, owner } = await setup({ maxOutputChars: 10 })
+ const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 })
await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true })
await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true })
+ await mkdir(join(root, 'dir', 'node_modules_old'), { recursive: true })
await mkdir(join(root, 'dir', '__pycache__'), { recursive: true })
+ await mkdir(join(root, 'dir', '__pycache__backup'), { recursive: true })
await writeFile(join(root, 'dir', 'visible.txt'), 'ok')
await writeFile(join(root, 'dir', '.hidden'), 'hidden')
await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child')
await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep')
await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency')
+ await writeFile(join(root, 'dir', 'node_modules_old', 'kept.js'), 'visible source')
await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache')
+ await writeFile(join(root, 'dir', '__pycache__backup', 'kept.py'), 'visible source')
const listDir = ctx.fs.listDir.bind(ctx.fs)
const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other'))
ctx.fs.listDir = async (target, signal) => {
@@ -156,14 +197,19 @@ describe('tool-str-replace-editor', () => {
}
const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') }))
- expect(listing).toContain('')
expect(listing).not.toContain('.hidden')
expect(listing).not.toContain('too-deep.txt')
expect(listing).not.toContain('index.js')
expect(listing).not.toContain('module.pyc')
+ expect(listing).toContain('node_modules_old/kept.js')
+ expect(listing).toContain('__pycache__backup/kept.py')
- await writeFile(join(root, 'large.txt'), 'x'.repeat(100))
- expect(text(await call(ctx, owner, { command: 'view', path: join(root, 'large.txt') })))
+ const clipped = await setup({ maxOutputChars: 10 })
+ await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100))
+ expect(text(await call(clipped.ctx, clipped.owner, {
+ command: 'view',
+ path: join(clipped.root, 'large.txt'),
+ })))
.toContain('')
})
@@ -233,10 +279,20 @@ describe('tool-str-replace-editor', () => {
expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]')
expect(text(repeated)).not.toContain('replace_all')
+ await writeFile(ambiguous, 'alpha\nbeta\nmiddle\nalpha\nbeta')
+ const repeatedMultiline = await call(ctx, owner, {
+ command: 'str_replace',
+ path: ambiguous,
+ old_str: 'alpha\nbeta',
+ new_str: 'x',
+ })
+ expect(text(repeatedMultiline))
+ .toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]')
+
const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' })
expect(relative.isError).toBe(true)
expect(text(relative)).toContain('is not an absolute path')
- expect(await readFile(ambiguous, 'utf8')).toBe('same\nother\nsame')
+ expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta')
})
it('reports invalid commands or arguments without mutating files', async () => {
@@ -302,6 +358,63 @@ describe('tool-str-replace-editor', () => {
.toContain("Here's the content of")
})
+ it('delegates read-before-edit decisions to fs-policy', async () => {
+ const { ctx, root, owner } = await setup({}, { fsPolicy: true })
+ const existing = join(root, 'existing.txt')
+ const created = join(root, 'created.txt')
+ await writeFile(existing, 'before')
+
+ const blindEdit = await call(ctx, owner, {
+ command: 'str_replace',
+ path: existing,
+ old_str: 'before',
+ new_str: 'after',
+ })
+ expect(blindEdit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
+ expect(await readFile(existing, 'utf8')).toBe('before')
+
+ await call(ctx, owner, { command: 'view', path: existing })
+ expect((await call(ctx, owner, {
+ command: 'str_replace',
+ path: existing,
+ old_str: 'before',
+ new_str: 'after',
+ })).isError).toBe(false)
+ expect(await readFile(existing, 'utf8')).toBe('after')
+
+ expect((await call(ctx, owner, {
+ command: 'create',
+ path: created,
+ file_text: 'new',
+ })).isError).toBe(false)
+ expect(await readFile(created, 'utf8')).toBe('new')
+ })
+
+ it('passes the session sandbox policy to every mutation', async () => {
+ const { ctx, root, owner } = await setup({}, { sandboxMode: 'read-only' })
+ const path = join(root, 'blocked.txt')
+ const result = await call(ctx, owner, {
+ command: 'create',
+ path,
+ file_text: 'blocked',
+ })
+ expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
+ expect(text(result)).toContain('[sandbox: file access denied under read-only mode]')
+ })
+
+ it('can preserve tabs outside the edited region', async () => {
+ const { ctx, root, owner } = await setup({ expandTabsOnMutation: false })
+ const path = join(root, 'Makefile')
+ await writeFile(path, 'target:\n\told\n')
+ await call(ctx, owner, {
+ command: 'str_replace',
+ path,
+ old_str: 'old',
+ new_str: 'new',
+ })
+ expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n')
+ })
+
it('rejects invalid plugin config', () => {
expect(() => {
ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 })
diff --git a/packages/fs/tool-str-replace-editor/tsconfig.json b/packages/fs/tool-str-replace-editor/tsconfig.json
index 2c6eb3688c..6ee0dc19eb 100644
--- a/packages/fs/tool-str-replace-editor/tsconfig.json
+++ b/packages/fs/tool-str-replace-editor/tsconfig.json
@@ -9,6 +9,8 @@
{ "path": "../../../vendor/cordis" },
{ "path": "../../core/tools" },
{ "path": "../fs" },
+ { "path": "../../sandbox/sandbox" },
+ { "path": "../../sandbox/sandbox-policy" },
{ "path": "../../support/invariants" }
]
}
diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts
index b812fe8992..8ba51388cf 100644
--- a/packages/pty/tool-bash-persistent/src/index.ts
+++ b/packages/pty/tool-bash-persistent/src/index.ts
@@ -16,7 +16,10 @@ const LOST_PREFIX_MESSAGE = 'The beginning of this comma
const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.'
const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
const TIMEOUT_CODE = 'PERSISTENT_BASH_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 bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.'
@@ -101,7 +104,7 @@ function commandOutput(
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
return {
text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
- incomplete: startMarker < 0 || snapshot.truncated,
+ incomplete: startMarker < 0,
}
}
@@ -115,22 +118,29 @@ function partialOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
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: snapshot.truncated,
+ 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(fallback),
- incomplete: snapshot.truncated,
+ text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')),
+ incomplete: fallbackTruncated || fallbackStart < 0,
}
}
async function pause(): Promise {
- await new Promise(resolve => setTimeout(resolve, 25))
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
}
function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined {
@@ -142,11 +152,13 @@ function retainedScrollback(
ctx: Context,
owner: Agent,
id: PtySessionId,
+ latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }),
): RetainedOutput {
- const pages: string[] = []
- let offset = 0
- let truncated = false
+ 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)
@@ -167,7 +179,10 @@ function renderCaptured(output: CapturedOutput, maxOutputChars: number): string
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()
+ let disposed = false
const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => {
if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return
@@ -175,6 +190,9 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
}
ctx.effect(() => async () => {
+ disposed = true
+ lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation'))
+ await Promise.allSettled([...creating])
const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') })
await Promise.all(closing)
live.clear()
@@ -188,15 +206,17 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
}
const get = (owner: Agent, signal: AbortSignal): Promise => {
+ if (disposed) return Promise.reject(new Error('tool-bash-persistent is disposed'))
const existing = pending.get(owner)
if (existing !== undefined) return existing
- const creating = (async () => {
+ 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 },
- }, signal)
+ }, combinedSignal)
live.set(owner, spawned.sessionId)
if (!ownerCleanupInstalled.has(owner)) {
ownerCleanupInstalled.add(owner)
@@ -208,7 +228,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
const setup = ctx.pty.startSend(owner, spawned.sessionId, {
text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`,
submit: true,
- signal,
+ signal: combinedSignal,
})
const result = await setup.done
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
@@ -220,8 +240,12 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
throw error
}
})()
- pending.set(owner, creating)
- return creating
+ const tracked = creation.finally(() => {
+ creating.delete(tracked)
+ })
+ creating.add(tracked)
+ pending.set(owner, tracked)
+ return tracked
}
return { get, reset }
@@ -241,21 +265,32 @@ async function executeCommand(
const wrapped = wrapCommand(command, marker)
let first = true
let fallback = ''
+ let fallbackTruncated = false
while (true) {
- const operation = ctx.pty.startSend(owner, id, {
- text: first ? wrapped : '',
- submit: first,
- signal: commandDeadline.signal,
- })
- first = false
- const result = await operation.done
- fallback += result.viewport
- const snapshot = retainedScrollback(ctx, owner, id)
+ 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 bash 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, fallback),
+ partialOutput(snapshot, marker, fallback, fallbackTruncated),
config.maxOutputChars,
)
await shells.reset(owner, 'persistent bash command timed out')
@@ -265,12 +300,15 @@ async function executeCommand(
SHELL_RESET_MESSAGE,
].join('\n')
}
- const complete = commandOutput(snapshot, marker)
- if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
+ if (latest.text.includes(marker.end)) {
+ const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker)
+ 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 [
- renderCaptured(partialOutput(snapshot, marker, fallback), config.maxOutputChars),
+ renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars),
SHELL_RESET_MESSAGE,
].filter(part => part.length > 0).join('\n')
}
@@ -279,7 +317,11 @@ async function executeCommand(
commandDeadline.signal.throwIfAborted()
}
if (promptCompleted(result)) {
- return maybeTruncate(stripPrompt(fallback), config.maxOutputChars, result.truncated)
+ const snapshot = retainedScrollback(ctx, owner, id, latest)
+ return renderCaptured(
+ partialOutput(snapshot, marker, fallback, fallbackTruncated),
+ config.maxOutputChars,
+ )
}
await pause()
}
diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts
index f757d6d1f8..3863ec29ce 100644
--- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts
+++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts
@@ -85,6 +85,8 @@ type StubMode =
| 'init-exit'
| 'init-timeout'
| 'spawn-error'
+ | 'send-error'
+ | 'prompt-after-idle'
class StubPtySession implements PtyBackendSession {
readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
@@ -95,6 +97,7 @@ class StubPtySession implements PtyBackendSession {
mode: StubMode
sends = 0
pendingText = ''
+ historyTruncated = false
constructor(mode: StubMode) {
this.mode = mode
@@ -112,6 +115,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.mode === 'wait-for-abort') {
const done = new Promise>((resolve) => {
request.signal?.addEventListener('abort', () => {
@@ -126,6 +130,17 @@ class StubPtySession implements PtyBackendSession {
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 = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.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 = `bash: 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 = `bash: syntax error${newline}${this.motd}${newline}`
@@ -166,7 +181,7 @@ class StubPtySession implements PtyBackendSession {
totalLines: lines.length,
lineBegin: 0,
lineEnd: lines.length,
- truncated: false,
+ truncated: this.historyTruncated,
}
}
@@ -316,6 +331,29 @@ describe('tool-bash-persistent', () => {
expect(text(await call(ctx, owner, 'stalled page'))).toContain('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('bash: syntax error')
+ expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
+ expect(result).not.toContain('DSH_PERSISTENT_BASH_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')
@@ -359,6 +397,48 @@ describe('tool-bash-persistent', () => {
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 bash 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(ToolBashPersistent, { 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)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4c57d106a6..73092a292c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -593,6 +593,9 @@ importers:
'@deepseek-ai/dsh-tool-ask-user':
specifier: workspace:*
version: link:../packages/ui/tool-ask-user
+ '@deepseek-ai/dsh-tool-bash-persistent':
+ specifier: workspace:*
+ version: link:../packages/pty/tool-bash-persistent
'@deepseek-ai/dsh-tool-cordis':
specifier: workspace:*
version: link:../packages/cordis/tool-cordis
@@ -617,6 +620,9 @@ importers:
'@deepseek-ai/dsh-tool-session-query':
specifier: workspace:*
version: link:../packages/session-query/tool-session-query
+ '@deepseek-ai/dsh-tool-str-replace-editor':
+ specifier: workspace:*
+ version: link:../packages/fs/tool-str-replace-editor
'@deepseek-ai/dsh-tool-subagent':
specifier: workspace:*
version: link:../packages/subagent/tool-subagent
@@ -2593,12 +2599,24 @@ importers:
'@deepseek-ai/dsh-fs-local':
specifier: workspace:^
version: link:../fs-local
+ '@deepseek-ai/dsh-fs-policy':
+ specifier: workspace:^
+ version: link:../fs-policy
+ '@deepseek-ai/dsh-fs-sandbox':
+ specifier: workspace:^
+ version: link:../fs-sandbox
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/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
diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml
index 44fcf94b60..4b104ec211 100644
--- a/python/sdk-runtime/README.i18n.yaml
+++ b/python/sdk-runtime/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write python/sdk-runtime/README.md
-README.md: 977bce41191d6c7716548dafde81d2c7ec14dec7
-README.zh.md: ade8455c56c27fcbe3e43a68abeaad98421cf720
+README.md: ee3791eddf26b526316d4f3952793a03cc48841e
+README.zh.md: 59d40ee56688cb377902ff126b7fa77606c7ad8b
diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md
index 977bce4119..ee3791eddf 100644
--- a/python/sdk-runtime/README.md
+++ b/python/sdk-runtime/README.md
@@ -15,7 +15,7 @@ Both carriers hold the same content, defined once: the [package.json](package.js
A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers.
-Each wheel contains exactly one runtime executable and its matching native spawn helper. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it.
+Each wheel contains exactly one runtime executable and its matching native spawn helper. A missing sidecar makes the runtime installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools; old exe-only wheels are intentionally unsupported. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it.
## Resolution API
diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md
index ade8455c56..59d40ee566 100644
--- a/python/sdk-runtime/README.zh.md
+++ b/python/sdk-runtime/README.zh.md
@@ -15,7 +15,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,
exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。
-每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。
+每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。缺少伴随文件意味着运行时安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此;旧的仅 exe wheel 有意不再兼容。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。
## 解析 API
diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts
index 03c82b25b0..38400009ac 100644
--- a/scripts/build-exe-for-python-sdk.ts
+++ b/scripts/build-exe-for-python-sdk.ts
@@ -260,7 +260,9 @@ class SingleExeBuild {
'--config.node-linker=hoisted',
'--config.auto-install-peers=false',
'--config.link-workspace-packages=true',
- // The production closure intentionally omits patched dev-only packages.
+ // The production closure intentionally omits the patched dev-only
+ // @earendil-works/pi-tui package. The root frozen install still validates
+ // every patch; this exception is scoped only to the production deploy.
'--config.allow-unused-patches=true',
this.staging,
])
diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py
index 11500b5ec7..346818e5fa 100644
--- a/scripts/smoke-python-runtime.py
+++ b/scripts/smoke-python-runtime.py
@@ -27,7 +27,7 @@ WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value witho
WORKFLOW_WORKER_TEXT = "workflow worker smoke ok"
PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor."
PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok"
-PERSISTENT_EDITOR_PATH: str | None = None
+PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: "
PERSISTENT_BASH_COMMAND = (
"counter=$(( ${counter:-0} + 1 )); export counter; "
"printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; "
@@ -198,7 +198,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
raise AssertionError(f"unexpected tool follow-up: {tool_name}")
prompt = message_text(latest.get("content"))
- if prompt == PERSISTENT_TOOLS_PROMPT:
+ if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"):
names = advertised_tool_names(body)
if names != {"bash", "str_replace_editor"}:
raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}")
@@ -261,14 +261,27 @@ def persistent_tool_followup(
if call_id == "persistent-bash-2" and tool_name == "bash":
if "COUNT=2 CWD=/tmp" not in tool_text:
raise AssertionError(f"persistent bash did not retain state: {tool_text}")
- if PERSISTENT_EDITOR_PATH is None:
- raise AssertionError("persistent editor smoke path was not initialized")
+ messages = body.get("messages")
+ if not isinstance(messages, list):
+ raise AssertionError("persistent editor smoke request has no messages")
+ editor_path = next(
+ (
+ text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip()
+ for message in messages
+ if isinstance(message, dict) and message.get("role") == "user"
+ for text in [message_text(message.get("content"))]
+ if PERSISTENT_EDITOR_PATH_PREFIX in text
+ ),
+ None,
+ )
+ if editor_path is None:
+ raise AssertionError("persistent editor smoke prompt has no editor path")
return tool_call_chunks(
"persistent-editor",
"str_replace_editor",
{
"command": "create",
- "path": PERSISTENT_EDITOR_PATH,
+ "path": editor_path,
"file_text": "created by packaged editor\n",
},
)
@@ -545,12 +558,12 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None:
"""Exercise native PTY state and the editor through the packaged executable."""
- global PERSISTENT_EDITOR_PATH
from deepseek_harness import DeepSeekHarness
with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary:
root = Path(temporary).resolve()
- PERSISTENT_EDITOR_PATH = str(root / "created.txt")
+ editor_path = root / "created.txt"
+ prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}"
sessions = root / "sessions"
cordis = root / "cordis.yml"
cordis.write_text(PERSISTENT_TOOLS_CORDIS)
@@ -565,17 +578,15 @@ def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None:
base_url=base_url,
request_timeout_seconds=60,
) as harness:
- result = harness.run(PERSISTENT_TOOLS_PROMPT, session_id="persistent-tools-smoke")
+ result = harness.run(prompt, session_id="persistent-tools-smoke")
assert result.status == "ok", result
event_text = json.dumps(result.events)
if PERSISTENT_TOOLS_TEXT not in event_text:
raise AssertionError(f"packaged tools run emitted no final response: {result.events}")
- created = root / "created.txt"
- if created.read_text() != "created by packaged editor\n":
- raise AssertionError(f"packaged editor wrote unexpected content: {created.read_text()!r}")
+ if editor_path.read_text() != "created by packaged editor\n":
+ raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}")
assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp")
- PERSISTENT_EDITOR_PATH = None
def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
From df58af92cda9b7d7a61e648ec212c07e31707c1d Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Wed, 29 Jul 2026 15:33:46 +0800
Subject: [PATCH 006/144] test(tools): close persistent tool coverage gaps
---
docs/config-catalog.md | 2 +-
.../fs/tool-str-replace-editor/src/index.ts | 2 +-
.../tests/tools.spec.ts | 101 +++++++++++++++++-
.../pty/tool-bash-persistent/src/index.ts | 15 +--
.../tool-bash-persistent/tests/tools.spec.ts | 13 ++-
5 files changed, 113 insertions(+), 20 deletions(-)
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 0e92e4e06e..2e6ffaf3d8 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1593,7 +1593,7 @@ export interface Config {
}
```
-Source: [`packages/pty/tool-bash-persistent/src/index.ts:382`](../packages/pty/tool-bash-persistent/src/index.ts)
+Source: [`packages/pty/tool-bash-persistent/src/index.ts:373`](../packages/pty/tool-bash-persistent/src/index.ts)
## `@deepseek-ai/dsh-tool-cordis`
diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts
index 5d9855c1da..7ff0773107 100644
--- a/packages/fs/tool-str-replace-editor/src/index.ts
+++ b/packages/fs/tool-str-replace-editor/src/index.ts
@@ -281,7 +281,7 @@ async function createFile(
outcome = await ctx.fs.writeText(
target,
content,
- intent ?? { kind: 'createIfAbsent' },
+ intent,
exec.signal,
sandboxPolicy,
)
diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts
index 213161e147..fb1e0d4b58 100644
--- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts
+++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts
@@ -129,6 +129,24 @@ describe('tool-str-replace-editor', () => {
kind: 'edit',
locations: [{ path: '/workspace/a.txt', line: 1 }],
})
+ expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
+ command: 'create',
+ path: '/workspace/empty.txt',
+ })).toMatchObject({
+ diffs: [{ path: '/workspace/empty.txt', oldText: null, newText: '' }],
+ })
+ expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
+ command: 'str_replace',
+ path: '/workspace/a.txt',
+ })).toMatchObject({
+ diffs: [{ path: '/workspace/a.txt', oldText: null, newText: '' }],
+ })
+ expect(ctx.tools.get('str_replace_editor')?.presentCall?.({
+ command: 'insert',
+ path: '/workspace/a.txt',
+ })).toMatchObject({
+ locations: [{ path: '/workspace/a.txt' }],
+ })
})
it('creates, views, replaces, and inserts with the canonical model-facing output', async () => {
@@ -192,7 +210,11 @@ describe('tool-str-replace-editor', () => {
ctx.fs.listDir = async (target, signal) => {
const entries = await listDir(target, signal)
return target.displayPath === join(root, 'dir')
- ? [...entries, { name: 'other', type: 'other', target: otherTarget }]
+ ? [
+ { name: 'same-target', type: 'other', target: otherTarget },
+ { name: 'other', type: 'other', target: otherTarget },
+ ...entries.toReversed(),
+ ]
: entries
}
@@ -235,6 +257,11 @@ describe('tool-str-replace-editor', () => {
command: 'view',
path: plain,
}))).toContain(' 1 one')
+ expect((await call(ctx, undefined, {
+ command: 'create',
+ path: join(root, 'ownerless.txt'),
+ file_text: 'ownerless',
+ })).isError).toBe(false)
await call(ctx, owner, {
command: 'insert',
@@ -382,6 +409,14 @@ describe('tool-str-replace-editor', () => {
})).isError).toBe(false)
expect(await readFile(existing, 'utf8')).toBe('after')
+ expect((await call(ctx, owner, {
+ command: 'insert',
+ path: existing,
+ insert_line: 1,
+ new_str: 'tail',
+ })).isError).toBe(false)
+ expect(await readFile(existing, 'utf8')).toBe('after\ntail')
+
expect((await call(ctx, owner, {
command: 'create',
path: created,
@@ -400,19 +435,79 @@ describe('tool-str-replace-editor', () => {
})
expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
expect(text(result)).toContain('[sandbox: file access denied under read-only mode]')
+
+ const ownerless = await call(ctx, undefined, {
+ command: 'create',
+ path: join(root, 'ownerless-blocked.txt'),
+ file_text: 'blocked',
+ })
+ expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } })
})
it('can preserve tabs outside the edited region', async () => {
const { ctx, root, owner } = await setup({ expandTabsOnMutation: false })
const path = join(root, 'Makefile')
- await writeFile(path, 'target:\n\told\n')
+ await writeFile(path, 'target:\n\told\nremove\n')
await call(ctx, owner, {
command: 'str_replace',
path,
old_str: 'old',
new_str: 'new',
})
- expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n')
+ await call(ctx, owner, {
+ command: 'str_replace',
+ path,
+ old_str: 'remove\n',
+ })
+ await call(ctx, owner, {
+ command: 'insert',
+ path,
+ insert_line: 1,
+ new_str: '\tkept',
+ })
+ expect(await readFile(path, 'utf8')).toBe('target:\n\tkept\n\tnew\n')
+ })
+
+ it('reports missing sandbox-policy composition during plugin startup', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-missing-policy-'))
+ roots.push(root)
+ const ctx = new Context()
+ contexts.push(ctx)
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(LocalFileSystem, { cwd: root })
+ Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' })
+
+ await expect(ctx.plugin(ToolStrReplaceEditor))
+ .rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing')
+ })
+
+ it('maps unexpected backend write failures for replace and insert', async () => {
+ const { ctx, root, owner } = await setup()
+ const path = join(root, 'backend-error.txt')
+ await writeFile(path, 'old\n')
+ ctx.fs.writeText = async () => {
+ throw new Error('backend write failed')
+ }
+
+ const replace = await call(ctx, owner, {
+ command: 'str_replace',
+ path,
+ old_str: 'old',
+ new_str: 'new',
+ })
+ expect(replace.isError).toBe(true)
+ expect(text(replace)).toContain('backend write failed')
+
+ const insert = await call(ctx, owner, {
+ command: 'insert',
+ path,
+ insert_line: 1,
+ new_str: 'new',
+ })
+ expect(insert.isError).toBe(true)
+ expect(text(insert)).toContain('backend write failed')
})
it('rejects invalid plugin config', () => {
diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts
index 8ba51388cf..136dcbaabd 100644
--- a/packages/pty/tool-bash-persistent/src/index.ts
+++ b/packages/pty/tool-bash-persistent/src/index.ts
@@ -81,12 +81,7 @@ function wrapCommand(command: string, marker: CommandMarkers): string {
}
function stripPrompt(text: string): string {
- let result = text
- while (result.endsWith(`${SHELL_PROMPT}\r\n`) || result.endsWith(`${SHELL_PROMPT}\n`)) {
- result = result.slice(0, result.endsWith('\r\n')
- ? -SHELL_PROMPT.length - 2
- : -SHELL_PROMPT.length - 1)
- }
+ let result = text.replace(/\r?\n$/, '')
while (result.endsWith(SHELL_PROMPT)) {
result = result.slice(0, -SHELL_PROMPT.length)
}
@@ -96,10 +91,9 @@ function stripPrompt(text: string): string {
function commandOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
-): CapturedOutput | undefined {
+): CapturedOutput {
const text = snapshot.text
const end = text.lastIndexOf(marker.end)
- if (end < 0) return undefined
const startMarker = text.lastIndexOf(marker.start, end)
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
return {
@@ -182,7 +176,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
const creating = new Set>()
const ownerCleanupInstalled = new WeakSet()
const lifecycle = new AbortController()
- let disposed = false
const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => {
if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return
@@ -190,7 +183,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
}
ctx.effect(() => async () => {
- disposed = true
lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation'))
await Promise.allSettled([...creating])
const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') })
@@ -206,7 +198,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell
}
const get = (owner: Agent, signal: AbortSignal): Promise => {
- if (disposed) return Promise.reject(new Error('tool-bash-persistent is disposed'))
const existing = pending.get(owner)
if (existing !== undefined) return existing
const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
@@ -302,7 +293,7 @@ async function executeCommand(
}
if (latest.text.includes(marker.end)) {
const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker)
- if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
+ return renderCaptured(complete, config.maxOutputChars)
}
if (result.sessionStatus.kind === 'exited') {
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 3863ec29ce..811d733f2f 100644
--- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts
+++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts
@@ -87,6 +87,7 @@ type StubMode =
| 'spawn-error'
| 'send-error'
| 'prompt-after-idle'
+ | 'empty-page-after-latest'
class StubPtySession implements PtyBackendSession {
readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
@@ -168,19 +169,22 @@ class StubPtySession implements PtyBackendSession {
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
- read(_request: PtyReadRequest) {
+ 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')
return {
text: this.scrollback,
- totalLines: lines.length,
+ totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length,
lineBegin: 0,
- lineEnd: lines.length,
+ lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length,
truncated: this.historyTruncated,
}
}
@@ -329,6 +333,9 @@ describe('tool-bash-persistent', () => {
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('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
From b9bc1c8a612f4235cabcd7c1259cd1ecf24bfe0b Mon Sep 17 00:00:00 2001
From: Yichen Jiang
Date: Wed, 29 Jul 2026 16:11:30 +0800
Subject: [PATCH 007/144] fix(python-sdk): read nested assistant messages
---
python/sdk/src/deepseek_harness/api.py | 4 +++-
python/sdk/tests/test_client.py | 7 ++++++-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py
index 5986dc2cdc..29bb4223ac 100644
--- a/python/sdk/src/deepseek_harness/api.py
+++ b/python/sdk/src/deepseek_harness/api.py
@@ -191,7 +191,9 @@ def final_response(events: list[JsonObject]) -> str:
data = event.get("data")
if not isinstance(data, dict):
continue
- content = data.get("content")
+ message = data.get("message")
+ content_owner = message if isinstance(message, dict) else data
+ content = content_owner.get("content")
if not isinstance(content, list):
continue
parts: list[str] = []
diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py
index de2927c598..52ceac9f4d 100644
--- a/python/sdk/tests/test_client.py
+++ b/python/sdk/tests/test_client.py
@@ -46,7 +46,12 @@ for line in sys.stdin:
"sessionId": params["sessionId"],
"event": {
"type": "assistant/message",
- "data": {"content": [{"type": "text", "text": "hello from runtime"}]},
+ "data": {
+ "message": {
+ "role": "assistant",
+ "content": [{"type": "text", "text": "hello from runtime"}],
+ },
+ },
},
},
}), flush=True)
From 42e3cceb6489258c142f51bb3ad54d4e6fc5c2d7 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Tue, 28 Jul 2026 22:19:13 +0800
Subject: [PATCH 008/144] feat(paths): add resolveSessionsRoot as the one
shared session-store root
Every surface that persists or lists sessions resolves one directory under
the Harness home, so history is shared across working directories instead
of scattered per project.
---
packages/util/paths/README.i18n.yaml | 4 ++--
packages/util/paths/README.md | 4 ++++
packages/util/paths/README.zh.md | 8 ++++++--
packages/util/paths/src/index.ts | 21 +++++++++++++++++++++
packages/util/paths/tests/paths.spec.ts | 11 +++++++++++
5 files changed, 44 insertions(+), 4 deletions(-)
diff --git a/packages/util/paths/README.i18n.yaml b/packages/util/paths/README.i18n.yaml
index a57d18b601..f21ee7f88b 100644
--- a/packages/util/paths/README.i18n.yaml
+++ b/packages/util/paths/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/util/paths/README.md
-README.md: b28e684f3183d739c8e229a9b341801dbf345d86
-README.zh.md: ab4e8123d19fd56749e3e7a0d59e8cd6bea0c3d0
+README.md: 837ee0cdf7687fac58058e04ff5971002124038a
+README.zh.md: d34eeb023a506d5c07f32638ac74698b3a913efa
diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md
index b28e684f31..837ee0cdf7 100644
--- a/packages/util/paths/README.md
+++ b/packages/util/paths/README.md
@@ -16,6 +16,10 @@ Shared filesystem path helpers for DeepSeek Harness user data.
`expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched.
+## Session store
+
+`resolveSessionsRoot()` resolves the shared session-store root under the Harness home, by the same precedence as `resolveDshHome()`. `SESSIONS_DIR_NAME` owns its directory name: `sessions`. Every surface that persists sessions resolves this one directory, so history is shared across working directories instead of scattered per project; a persistence backend may still partition inside it. Two surfaces resolving different roots would silently split one user's history into disjoint stores, which is why the location is owned here rather than joined per caller.
+
This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another.
## Known Limitations and Deferred Work
diff --git a/packages/util/paths/README.zh.md b/packages/util/paths/README.zh.md
index ab4e8123d1..d34eeb023a 100644
--- a/packages/util/paths/README.zh.md
+++ b/packages/util/paths/README.zh.md
@@ -16,9 +16,13 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。
`expandHomePath()` 使用操作系统主目录展开 `~`、`~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。
-该包(package)刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。
+## 会话存储
-## 已知限制与暂缓事项
+`resolveSessionsRoot()` 按与 `resolveDshHome()` 相同的优先级,解析 Harness 主目录下的共享会话存储根目录。`SESSIONS_DIR_NAME` 定义其目录名:`sessions`。每个持久化会话的界面都解析这同一个目录,因此历史记录在各工作目录之间共享,而不是按项目分散;持久化后端仍可在其内部分区。若两个界面解析出不同的根目录,会静默地把同一用户的历史记录拆成互不相交的存储,这正是该位置由此处拥有、而非由各调用方自行拼接的原因。
+
+该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。
+
+## 已知限制与待完成工作
- **展开范围刻意保持狭窄**:只有单独的 `~`、`~/...` 和 `~\...` 使用当前操作系统主目录;`~alice/...` 等指定用户的形式、环境变量和 shell 表达式保持不变。
- **辅助工具不会操作文件系统**:调用方仍负责目录创建、存在性检查、权限,以及对结果路径应用信任策略。
diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts
index c54a5e0a5f..85a6c881cd 100644
--- a/packages/util/paths/src/index.ts
+++ b/packages/util/paths/src/index.ts
@@ -52,6 +52,27 @@ export function resolveDshHome(configured?: string, env: Record = process.env,
+): string {
+ return join(resolveDshHome(configuredHome, env), SESSIONS_DIR_NAME)
+}
+
/**
* Describe a resolved harness home symbolically for user-facing display.
*
diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts
index 6e1b94b1e9..5d41b52c44 100644
--- a/packages/util/paths/tests/paths.spec.ts
+++ b/packages/util/paths/tests/paths.spec.ts
@@ -4,10 +4,12 @@ import { describe, expect, it } from 'vitest'
import {
DEFAULT_DSH_HOME_DISPLAY,
DSH_HOME_DIR_NAME,
+ SESSIONS_DIR_NAME,
defaultDshHome,
dshHomeDisplay,
expandHomePath,
resolveDshHome,
+ resolveSessionsRoot,
} from '@deepseek-ai/dsh-paths'
describe('dsh path helpers', () => {
@@ -38,6 +40,15 @@ describe('dsh path helpers', () => {
expect(resolveDshHome(undefined, { DSH_HOME: ' ' })).toBe(defaultDshHome())
})
+ it('resolves the session store under the home it was given, by the same precedence', () => {
+ expect(SESSIONS_DIR_NAME).toBe('sessions')
+ expect(resolveSessionsRoot('/tmp/explicit-dsh', { DSH_HOME: '~/env-dsh' }))
+ .toBe(join(resolve('/tmp/explicit-dsh'), 'sessions'))
+ expect(resolveSessionsRoot(undefined, { DSH_HOME: '~/env-dsh' }))
+ .toBe(join(homedir(), 'env-dsh', 'sessions'))
+ expect(resolveSessionsRoot(undefined, {})).toBe(join(defaultDshHome(), 'sessions'))
+ })
+
it('labels a resolved home by whether it is the default root', () => {
expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh')
expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME')
From e7c0a5b7947232fd8eaedab013e9f2d5d3de846d Mon Sep 17 00:00:00 2001
From: Turtle
Date: Wed, 29 Jul 2026 14:29:32 +0800
Subject: [PATCH 009/144] Merge origin/master: web permission sandbox, default
pi-ai providers
---
...8-launcher-owned-resume-identity.i18n.yaml | 6 +
...26-07-28-launcher-owned-resume-identity.md | 62 ++
...07-28-launcher-owned-resume-identity.zh.md | 62 ++
...cated-full-screen-tui-front-door.i18n.yaml | 4 +-
...17-dedicated-full-screen-tui-front-door.md | 2 +-
...dedicated-full-screen-tui-front-door.zh.md | 2 +-
.../2026-07-21-tui-resume-command.i18n.yaml | 6 +-
.../feature/2026-07-21-tui-resume-command.md | 12 +-
.../2026-07-21-tui-resume-command.zh.md | 12 +-
...24-configurable-tui-prompt-theme.i18n.yaml | 6 +-
...026-07-24-configurable-tui-prompt-theme.md | 2 +-
...-07-24-configurable-tui-prompt-theme.zh.md | 2 +-
...2026-07-27-tmux-location-context.i18n.yaml | 6 +
.../2026-07-27-tmux-location-context.md | 61 ++
.../2026-07-27-tmux-location-context.zh.md | 61 ++
.../2026-07-27-tui-tool-card-header.i18n.yaml | 4 +-
.../2026-07-27-tui-tool-card-header.md | 2 +-
.../2026-07-27-tui-tool-card-header.zh.md | 2 +-
...026-07-28-cross-workspace-resume.i18n.yaml | 6 +
.../2026-07-28-cross-workspace-resume.md | 52 ++
.../2026-07-28-cross-workspace-resume.zh.md | 52 ++
...sh-guided-skill-session-commands.i18n.yaml | 6 +
...07-28-dsh-guided-skill-session-commands.md | 43 ++
...28-dsh-guided-skill-session-commands.zh.md | 43 ++
...-07-28-dsh-meta-source-workspace.i18n.yaml | 6 +
.../2026-07-28-dsh-meta-source-workspace.md | 49 ++
...2026-07-28-dsh-meta-source-workspace.zh.md | 49 ++
...live-session-registry-and-dsh-ls.i18n.yaml | 6 +
...-07-28-live-session-registry-and-dsh-ls.md | 65 ++
...-28-live-session-registry-and-dsh-ls.zh.md | 65 ++
...8-source-guard-staging-edit-gate.i18n.yaml | 6 +
...26-07-28-source-guard-staging-edit-gate.md | 76 +++
...07-28-source-guard-staging-edit-gate.zh.md | 76 +++
apps/cli/README.md | 14 +-
apps/cli/README.zh.md | 14 +-
apps/cli/package.json | 3 +
apps/cli/src/app-cli-entry.ts | 11 +-
apps/cli/src/args.ts | 149 ++++-
apps/cli/src/bin.ts | 16 +
apps/cli/src/headless.ts | 2 +
apps/cli/src/list-sessions.ts | 101 +++
apps/cli/src/register-session.ts | 44 ++
apps/cli/src/tui.ts | 144 ++++-
apps/cli/src/web.ts | 2 +
apps/cli/tests/args.spec.ts | 39 +-
apps/cli/tests/built-bin.e2e.ts | 55 +-
apps/cli/tests/list-sessions.spec.ts | 74 +++
apps/cli/tests/sessions-root.spec.ts | 20 +
apps/cli/tsconfig.json | 9 +
docs/capability-seams.md | 8 +
docs/config-catalog.md | 86 ++-
docs/cordis-catalog/services.md | 38 ++
docs/i18n/terminology.md | 1 +
docs/module-graph.md | 33 +
examples/cordis-agent/cordis.yml | 1 -
.../fixtures/guard/source-guard/cordis.yml | 38 ++
.../fixtures/guard/source-guard/mock-llm.ts | 43 ++
.../guard/source-guard/mount-guard.ts | 14 +
.../tests/fixtures/tmux-context-driver.ts | 16 +
.../tests/fixtures/tmux-context-mock-bash.ts | 46 ++
.../tests/fixtures/tmux-context-mock-llm.ts | 22 +
.../tests/fixtures/tmux-context.cordis.yml | 21 +
examples/package.json | 3 +
examples/tui-agent/README.md | 2 +-
examples/tui-agent/code-mode.cordis.yml | 2 -
examples/tui-agent/composition.md | 3 +
examples/tui-agent/cordis.yml | 23 +-
.../tests/fixtures/tui-scripted-llm.ts | 27 +-
.../tests/fixtures/tui-scripted.cordis.yml | 2 -
.../tui-agent/tests/tui-keyless-smoke.e2e.ts | 74 ++-
knip.json | 35 ++
packages/context/README.md | 5 +-
.../context/tmux-context/README.i18n.yaml | 6 +
packages/context/tmux-context/README.md | 68 ++
packages/context/tmux-context/README.zh.md | 68 ++
packages/context/tmux-context/package.json | 49 ++
packages/context/tmux-context/src/index.ts | 227 +++++++
.../context/tmux-context/src/invariant.ts | 30 +
.../tmux-context/tests/tmux-context.e2e.ts | 77 +++
.../tmux-context/tests/tmux-context.spec.ts | 367 +++++++++++
packages/context/tmux-context/tsconfig.json | 40 ++
.../cordis/tool-cordis/src/api-catalog.ts | 30 +
packages/examples/tui-demo/README.md | 8 +-
packages/examples/tui-demo/package.json | 16 +-
packages/examples/tui-demo/src/index.ts | 47 +-
.../examples/tui-demo/tests/tui-agent.spec.ts | 67 +-
packages/examples/tui-demo/tsconfig.json | 3 +
packages/guard/README.md | 5 +-
packages/guard/source-guard/README.i18n.yaml | 6 +
packages/guard/source-guard/README.md | 88 +++
packages/guard/source-guard/README.zh.md | 88 +++
packages/guard/source-guard/package.json | 56 ++
packages/guard/source-guard/src/index.ts | 319 ++++++++++
packages/guard/source-guard/src/invariant.ts | 85 +++
.../source-guard/tests/invariant.spec.ts | 134 ++++
.../tests/loader-composition.e2e.ts | 93 +++
.../source-guard/tests/source-guard.spec.ts | 581 ++++++++++++++++++
packages/guard/source-guard/tsconfig.json | 42 ++
packages/session-registry/README.i18n.yaml | 6 +
packages/session-registry/README.md | 15 +
packages/session-registry/README.zh.md | 15 +
.../session-registry-file/README.i18n.yaml | 6 +
.../session-registry-file/README.md | 42 ++
.../session-registry-file/README.zh.md | 42 ++
.../session-registry-file/package.json | 47 ++
.../session-registry-file/src/file.ts | 97 +++
.../session-registry-file/src/index.ts | 233 +++++++
.../session-registry-file/src/invariant.ts | 32 +
.../session-registry-file/src/liveness.ts | 30 +
.../tests/fixtures/register-once.ts | 27 +
.../tests/session-registry-file.spec.ts | 382 ++++++++++++
.../session-registry-file/tsconfig.json | 21 +
.../session-registry-live/README.i18n.yaml | 6 +
.../session-registry-live/README.md | 32 +
.../session-registry-live/README.zh.md | 32 +
.../session-registry-live/package.json | 44 ++
.../session-registry-live/src/index.ts | 84 +++
.../session-registry-live/src/invariant.ts | 31 +
.../tests/session-registry-live.spec.ts | 227 +++++++
.../session-registry-live/tsconfig.json | 24 +
.../session-registry/README.i18n.yaml | 6 +
.../session-registry/README.md | 30 +
.../session-registry/README.zh.md | 30 +
.../session-registry/package.json | 41 ++
.../session-registry/src/index.ts | 82 +++
.../session-registry/src/invariant.ts | 58 ++
.../session-registry/src/types.ts | 55 ++
.../session-registry/tests/invariant.spec.ts | 98 +++
.../session-registry/tsconfig.json | 21 +
.../support/acp-snapshot/README.i18n.yaml | 4 +-
packages/support/acp-snapshot/README.md | 2 +-
packages/support/acp-snapshot/README.zh.md | 2 +-
packages/support/acp-snapshot/src/harness.ts | 8 +
packages/support/acp-snapshot/src/suite.ts | 7 +
.../suite/plain-turn/stdout.expected.jsonl | 2 +-
.../support/acp-snapshot/tests/suite.spec.ts | 3 +-
packages/ui/app-boot/README.i18n.yaml | 4 +-
packages/ui/app-boot/README.md | 3 +-
packages/ui/app-boot/README.zh.md | 3 +-
packages/ui/app-boot/src/index.ts | 11 -
pnpm-lock.yaml | 188 ++++++
scripts/gen-cordis-catalog.ts | 2 +
scripts/gen-doc-graphs.ts | 9 +
.../verify-package-readme-model-experience.ts | 3 +
skills/dsh-migrate/SKILL.md | 57 ++
tsconfig.base.json | 2 +
tsconfig.host.json | 5 +
147 files changed, 6770 insertions(+), 195 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml
create mode 100644 .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md
create mode 100644 .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md
create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md
create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md
create mode 100644 .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
create mode 100644 .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md
create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md
create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md
create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md
create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md
create mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md
create mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md
create mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md
create mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md
create mode 100644 apps/cli/src/list-sessions.ts
create mode 100644 apps/cli/src/register-session.ts
create mode 100644 apps/cli/tests/list-sessions.spec.ts
create mode 100644 apps/cli/tests/sessions-root.spec.ts
create mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml
create mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts
create mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts
create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-driver.ts
create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts
create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts
create mode 100644 examples/headless-agent/tests/fixtures/tmux-context.cordis.yml
create mode 100644 packages/context/tmux-context/README.i18n.yaml
create mode 100644 packages/context/tmux-context/README.md
create mode 100644 packages/context/tmux-context/README.zh.md
create mode 100644 packages/context/tmux-context/package.json
create mode 100644 packages/context/tmux-context/src/index.ts
create mode 100644 packages/context/tmux-context/src/invariant.ts
create mode 100644 packages/context/tmux-context/tests/tmux-context.e2e.ts
create mode 100644 packages/context/tmux-context/tests/tmux-context.spec.ts
create mode 100644 packages/context/tmux-context/tsconfig.json
create mode 100644 packages/guard/source-guard/README.i18n.yaml
create mode 100644 packages/guard/source-guard/README.md
create mode 100644 packages/guard/source-guard/README.zh.md
create mode 100644 packages/guard/source-guard/package.json
create mode 100644 packages/guard/source-guard/src/index.ts
create mode 100644 packages/guard/source-guard/src/invariant.ts
create mode 100644 packages/guard/source-guard/tests/invariant.spec.ts
create mode 100644 packages/guard/source-guard/tests/loader-composition.e2e.ts
create mode 100644 packages/guard/source-guard/tests/source-guard.spec.ts
create mode 100644 packages/guard/source-guard/tsconfig.json
create mode 100644 packages/session-registry/README.i18n.yaml
create mode 100644 packages/session-registry/README.md
create mode 100644 packages/session-registry/README.zh.md
create mode 100644 packages/session-registry/session-registry-file/README.i18n.yaml
create mode 100644 packages/session-registry/session-registry-file/README.md
create mode 100644 packages/session-registry/session-registry-file/README.zh.md
create mode 100644 packages/session-registry/session-registry-file/package.json
create mode 100644 packages/session-registry/session-registry-file/src/file.ts
create mode 100644 packages/session-registry/session-registry-file/src/index.ts
create mode 100644 packages/session-registry/session-registry-file/src/invariant.ts
create mode 100644 packages/session-registry/session-registry-file/src/liveness.ts
create mode 100644 packages/session-registry/session-registry-file/tests/fixtures/register-once.ts
create mode 100644 packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts
create mode 100644 packages/session-registry/session-registry-file/tsconfig.json
create mode 100644 packages/session-registry/session-registry-live/README.i18n.yaml
create mode 100644 packages/session-registry/session-registry-live/README.md
create mode 100644 packages/session-registry/session-registry-live/README.zh.md
create mode 100644 packages/session-registry/session-registry-live/package.json
create mode 100644 packages/session-registry/session-registry-live/src/index.ts
create mode 100644 packages/session-registry/session-registry-live/src/invariant.ts
create mode 100644 packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts
create mode 100644 packages/session-registry/session-registry-live/tsconfig.json
create mode 100644 packages/session-registry/session-registry/README.i18n.yaml
create mode 100644 packages/session-registry/session-registry/README.md
create mode 100644 packages/session-registry/session-registry/README.zh.md
create mode 100644 packages/session-registry/session-registry/package.json
create mode 100644 packages/session-registry/session-registry/src/index.ts
create mode 100644 packages/session-registry/session-registry/src/invariant.ts
create mode 100644 packages/session-registry/session-registry/src/types.ts
create mode 100644 packages/session-registry/session-registry/tests/invariant.spec.ts
create mode 100644 packages/session-registry/session-registry/tsconfig.json
create mode 100644 skills/dsh-migrate/SKILL.md
diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml
new file mode 100644
index 0000000000..b63d7d3093
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md
+2026-07-28-launcher-owned-resume-identity.md: 8c48194892f67c1a0f3f87094cd174ca1a71a383
+2026-07-28-launcher-owned-resume-identity.zh.md: 88113017986ac8aaf473d246f701c977804353bf
diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md
new file mode 100644
index 0000000000..8c48194892
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md
@@ -0,0 +1,62 @@
+# Agent Note: Launcher-owned session identity and exit line
+
+Status: implemented
+
+English | [中文](2026-07-28-launcher-owned-resume-identity.zh.md)
+
+## Problem
+
+Two facts a launcher owns were shipped as deployment config keys on `dsh-tui-demo`: `resumeSessionId` (which session `main` binds to) and `resumeCommand` (the exit hint template, with `{session}` interpolated). Neither varies by deployment — both are properties of how the process was invoked, which only the launcher knows.
+
+Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured".
+
+Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume ` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause.
+
+A config key cannot express these facts safely, because the deployment is not the authority on them.
+
+## Decision
+
+Session identity and the exit line are launcher-owned context slots, provided before any Loader entry mounts. Neither appears in any `cordis.yml` or in `dsh-tui`'s or `dsh-tui-demo`'s `Config`.
+
+`dsh-tui` declares both slots beside the existing `tuiResumeHost` host capability, which set the precedent — a resume host has always been a provided capability rather than config:
+
+- `MAIN_SESSION_ID_KEY` carries a `MainSessionIdentity` (`{ id: SessionId, resume: boolean }`). `dsh-tui-demo` binds both the TUI and the configured agent to `id`, and takes the history-loading `resumeSessionId` path only when `resume` is set, because that path requires an existing log and fails loud without one. An absent slot means no launcher chose a session, so the app mints `main-session-` and creates it fresh.
+- `TUI_GOODBYE_MESSAGE_KEY` carries the complete line printed once the terminal is released on exit. Absent prints nothing.
+
+`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line now names `--config` when one was passed, and reproduces `dsh meta --resume ` in meta mode — closing the mode-aware hint deferred by the `dsh meta` note, where a copied hint previously only worked from the checkout.
+
+**`ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin.** Config `!!js` expressions evaluate as `with (entry.ctx) { eval(expr) }` (`vendor/loader/src/config/utils.ts`), so a bare identifier resolves against the entry's context and nothing else reaches it. The slot therefore cannot be removed while the app bundle is mounted from YAML; what changes is that it is now internal launcher↔app plumbing instead of a documented key a config author must wire correctly.
+
+The message is a plain string, not a callback. That forces the launcher to know the id before boot, which is why minting moved out of the app bundle — and it keeps exit free of awaited work after the terminal is released.
+
+The TUI owns rendering, not wording: it applies `displayText` before its own `palette.muted`, so a hostile `--config` path cannot inject terminal escapes into the exit line. Sanitizing means the launcher cannot embed its own ANSI.
+
+## Alternatives considered
+
+**Keep the keys and add built-in defaults in `dsh-tui-demo`.** Rejected: a default in code survives an overlay, but two ways to state one fact remain, and a config author can still set the key wrong — which is exactly how the stale `process.env.RESUME_SESSION_ID` line disabled resume.
+
+**Merge `dsh-tui-demo` into `apps/cli` and delete the slot entirely.** Rejected after investigation, though it is the only way to remove the slot. `examples/tui-agent/code-mode.cordis.yml` patches the `tui-agent` entry through a nested `plugin-include` to switch `tools.mode` and the persona, and `examples/cordis-agent/cordis.yml` reuses the bundle as a different product; both extension points exist only because `tui-agent` is a declared config entry. Merging also moves a 162-line, 18-dependency composition into the CLI's `v8 ignore` process-wiring block, out of the per-file coverage gate.
+
+**Put the goodbye message on `TuiResumeHost`.** Rejected: an exit line is not a handoff capability, and a host that cannot replace its process may still want to print one. They are independent slots.
+
+**Have the host supply only the command text and let the TUI keep the `To resume this session:` prefix.** Rejected: the TUI would retain resume vocabulary for a string it no longer understands, and meta mode proves the launcher is the only component that knows what the command should say.
+
+**Let the TUI keep suppressing the line until the session is durably persisted.** Rejected: that check is why the exit path queried persistence and swallowed listing failures. A plain string cannot consult persistence, and misuse now fails loud through `agent-loop/config-start-failed` rather than silently resuming nothing.
+
+**A callback (`goodbyeMessage(agent)`) so the host could decide at exit time.** Rejected: it restores async work after `ui.stop()`, reintroducing a hang risk during teardown for a string that is already knowable at boot.
+
+## Consequences
+
+- Removing two published `Config` keys is a breaking config change: a stale config naming either now fails schema validation at boot instead of degrading silently. Intended, and acceptable pre-release.
+- `TuiResumeHost` is unchanged, but `TuiRuntime` gains `goodbyeMessage`; `apps/cli` is the only provider.
+- The exit line prints even for a session with no log (launch, quit immediately). Using it then fails loud rather than starting a surprise session. This is the deliberate cost of dropping the persistence check.
+- `dsh-tui` no longer reads `sessionPersistence` at all: `currentResumeCommand`, `listWorkspaceSessions`, and its swallowed-error path are deleted, and the `/resume` selector's `sessionQuery` reads are now the only session discovery in the TUI.
+- The launcher mints session ids for its own app, so a non-CLI host that provides no slot keeps the bundle's own minting.
+
+## Testing
+
+`packages/ui/tui/tests/tui.spec.ts` pins the printed line, the absent-slot silence, and escape sanitization of a hostile message; the former two exit-suppression tests are replaced, since suppression is the behavior this change removes. `packages/examples/tui-demo/tests/tui-agent.spec.ts` drives the identity slot for the resume, launcher-minted, and no-slot cases through a fake `ctx.get`.
+
+The load-bearing coverage is `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`, which launches the real `apps/cli/src/bin.ts` in a PTY: one test asserts the exit line carries `--config`, and a regression test seeds a personal `config.yaml` that replaces the entire `tui-agent` config block and asserts the line still prints — encoding "an overlay cannot drop resume" as an executed contract rather than a comment.
+
+Verified live in tmux against the real personal overlay: the defect reproduced on unmodified staging (requested id ignored, fresh id in the banner), and on this branch the same overlay yields a printed exit line, a `--resume` that restores the prior turn, and a `/resume` selector marking the session `current · live · persisted`. A wrong id now fails loud.
diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md
new file mode 100644
index 0000000000..8811301798
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md
@@ -0,0 +1,62 @@
+# Agent Note:由启动器持有的会话身份与退出行
+
+Status: implemented
+
+[English](2026-07-28-launcher-owned-resume-identity.md) | 中文
+
+## Problem
+
+有两项本应由启动器持有的事实,却被作为 `dsh-tui-demo` 上的部署配置键交付:`resumeSessionId`(`main` 绑定到哪个会话)与 `resumeCommand`(退出提示的模板,其中 `{session}` 会被插值)。二者都不随部署而变——它们都是进程被如何调用的属性,而这一点只有启动器知道。
+
+把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay,会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。
+
+两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume ` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。
+
+一个配置键无法安全地表达这些事实,因为部署方并非它们的权威。
+
+## Decision
+
+会话身份与退出行是由启动器持有的上下文槽位,在任何 Loader 条目挂载之前提供。二者都不出现在任何 `cordis.yml` 中,也不出现在 `dsh-tui` 或 `dsh-tui-demo` 的 `Config` 中。
+
+`dsh-tui` 在既有的 `tuiResumeHost` 宿主能力旁声明这两个槽位,后者确立了先例——resume 宿主一直是一项被提供的能力,而非配置:
+
+- `MAIN_SESSION_ID_KEY` 承载一个 `MainSessionIdentity`(`{ id: SessionId, resume: boolean }`)。`dsh-tui-demo` 把 TUI 与所配置的 agent 都绑定到 `id`,并且仅当 `resume` 被置位时才走加载历史的 `resumeSessionId` 路径,因为该路径要求存在一份日志、否则会明确报错。槽位缺失意味着没有启动器选定会话,于是应用铸造 `main-session-` 并新建它。
+- `TUI_GOODBYE_MESSAGE_KEY` 承载退出时终端释放后打印一次的完整行。缺失则什么都不打印。
+
+`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行现在会在传入了 `--config` 时命名它,并在 meta 模式下复现 `dsh meta --resume `——从而收口了 `dsh meta` note 所推迟的随 mode 变化的提示,在那里被复制的提示此前只有在检出目录中才有效。
+
+**`ctx.provide` 是从启动器 argv 进入被 Loader 挂载的插件的唯一通道。** 配置的 `!!js` 表达式会以 `with (entry.ctx) { eval(expr) }`(`vendor/loader/src/config/utils.ts`)求值,因此一个裸标识符会针对该条目的上下文解析,别无它物可达。于是只要应用 bundle 仍从 YAML 挂载,这个槽位就无法被移除;变化之处在于它现在是启动器↔应用之间的内部管线,而不再是一个配置作者必须正确接线的、有文档记载的键。
+
+该消息是一个纯字符串,而非回调。这迫使启动器在启动前就知道 id,也正是铸造从应用 bundle 中移出的原因——并且它让退出在终端释放之后免于任何被 await 的工作。
+
+TUI 持有渲染,而非措辞:它在自己的 `palette.muted` 之前先应用 `displayText`,因此一个恶意的 `--config` 路径无法把终端转义序列注入退出行。做净化意味着启动器无法嵌入自己的 ANSI。
+
+## Alternatives considered
+
+**保留这些键,并在 `dsh-tui-demo` 中加入内建默认值。** 拒绝:代码中的默认值能在 overlay 下存活,但表达同一事实的两种途径依然并存,而配置作者仍可把键设错——这正是那行陈旧的 `process.env.RESUME_SESSION_ID` 使 resume 失效的方式。
+
+**把 `dsh-tui-demo` 合并进 `apps/cli` 并彻底删除该槽位。** 经调查后拒绝,尽管这是移除该槽位的唯一途径。`examples/tui-agent/code-mode.cordis.yml` 通过一个嵌套的 `plugin-include` 给 `tui-agent` 条目打补丁,以切换 `tools.mode` 与人设,而 `examples/cordis-agent/cordis.yml` 把该 bundle 作为另一款产品复用;这两个扩展点都仅因 `tui-agent` 是一个声明式配置条目才存在。合并还会把一段 162 行、18 个依赖的组合逻辑挪进 CLI 的 `v8 ignore` 进程接线块中,脱离逐文件覆盖率门禁。
+
+**把 goodbye 消息放到 `TuiResumeHost` 上。** 拒绝:退出行不是一项移交能力,而一个无法替换自身进程的宿主仍可能想要打印一行。它们是相互独立的槽位。
+
+**让宿主只提供命令文本,而由 TUI 保留 `To resume this session:` 前缀。** 拒绝:TUI 将为一个它已不再理解的字符串保留 resume 词汇,而 meta 模式证明启动器才是唯一知道该命令应当说什么的组件。
+
+**让 TUI 继续在会话被持久化之前抑制该行。** 拒绝:这项检查正是退出路径要查询持久化并吞掉列举失败的原因。一个纯字符串无法查询持久化,而误用现在会经由 `agent-loop/config-start-failed` 明确报错,而不是静默地恢复了个空。
+
+**用一个回调(`goodbyeMessage(agent)`)让宿主能在退出时决定。** 拒绝:它会在 `ui.stop()` 之后恢复异步工作,为一个在启动时就已可知的字符串,重新引入拆解期间的挂起风险。
+
+## Consequences
+
+- 移除两个已发布的 `Config` 键是一次破坏性配置变更:一份命名了任一键的陈旧配置,现在会在启动时的 schema 校验中明确报错,而不再静默降级。这是有意为之,且在预发布阶段可以接受。
+- `TuiResumeHost` 保持不变,但 `TuiRuntime` 新增 `goodbyeMessage`;`apps/cli` 是唯一的提供方。
+- 即便某会话没有日志(启动后立即退出),退出行也会打印。此时使用它会明确报错,而不是开启一个意外的会话。这是丢弃持久化检查的有意代价。
+- `dsh-tui` 完全不再读取 `sessionPersistence`:`currentResumeCommand`、`listWorkspaceSessions` 及其吞错路径都被删除,`/resume` 选择器的 `sessionQuery` 读取如今是 TUI 中唯一的会话发现途径。
+- 启动器为其自身的应用铸造会话 id,因此一个不提供任何槽位的非 CLI 宿主,仍保留 bundle 自带的铸造逻辑。
+
+## Testing
+
+`packages/ui/tui/tests/tui.spec.ts` 钉住打印出的行、槽位缺失时的静默,以及对恶意消息的转义净化;此前那两个退出抑制测试被替换,因为抑制正是本次改动移除的行为。`packages/examples/tui-demo/tests/tui-agent.spec.ts` 通过一个伪造的 `ctx.get`,为 resume、启动器铸造与无槽位三种情形驱动身份槽位。
+
+承重的覆盖是 `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`,它在一个 PTY 中拉起真实的 `apps/cli/src/bin.ts`:一个测试断言退出行携带 `--config`,一个回归测试植入一份个人 `config.yaml` 来替换整块 `tui-agent` 配置块并断言该行仍会打印——把「overlay 不能丢掉 resume」编码为一条被执行的契约,而非一句注释。
+
+在 tmux 中针对真实的个人 overlay 做过实测:该缺陷在未修改的 staging 上复现(所请求的 id 被忽略,banner 里是新的 id),而在本分支上同一份 overlay 会产出一行打印的退出行、一个能恢复上一轮次的 `--resume`,以及一个把该会话标记为 `current · live · persisted` 的 `/resume` 选择器。错误的 id 现在会明确报错。
diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml
index 3df1059e1f..bba253325c 100644
--- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages 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-17-dedicated-full-screen-tui-front-door.md
-2026-07-17-dedicated-full-screen-tui-front-door.md: a3f3d6b51e85ad20218ce5aebf526bd96946be55
-2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6a0c2f12815e9418a2b5bcb237d3db3616ccd133
+2026-07-17-dedicated-full-screen-tui-front-door.md: bc6241e925d5bf094deded761fb96fd6b6c48a1f
+2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0f5306ff547cb975b29d1fbcb7e610b3a540ce18
diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md
index a3f3d6b51e..bc6241e925 100644
--- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md
+++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md
@@ -24,7 +24,7 @@ The TUI rebuilds the transcript from the active `session.surface` and reprojects
Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. `/exit` and `/quit` share the same exit path: they cancel an active turn, wait for idle, and then restore and close the terminal. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; the panel's controls hint lists only actions meaningful for the current option count, omitting navigation when exactly one option is shown; agent behavior and answer logging remain owned by their existing services.
-The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local.
+The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. The selector carries a filter box above the list: typing narrows the rows to a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the selection on the previously highlighted row when it survives the filter; Escape clears a non-empty filter before a second Escape cancels the selector. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local.
### Terminal ownership
diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md
index 6a0c2f1281..0f5306ff54 100644
--- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md
@@ -24,7 +24,7 @@ TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在
agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。`/exit` 和 `/quit` 共用同一条退出路径:先取消进行中的轮次,等待 agent 空闲,然后恢复并关闭终端。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;面板的操作提示只列出在当前选项数量下有意义的操作,仅有一个选项时不显示导航项;agent 行为和答案日志仍由既有服务负责。
-`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。
+`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。选择器在列表上方设有一个过滤框:输入内容会按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在原先高亮行仍通过过滤时保持其选中状态;Escape 会先清空非空的过滤内容,再次按 Escape 才取消选择器。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。
### 终端所有权
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml
index d470b61414..ea5ef87849 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.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-tui-resume-command.md: 86f62e16f5e2ee83e2ed36f0ed675ca2a1422c4b
-2026-07-21-tui-resume-command.zh.md: 06e58f81445aaaf5299282714148194c1d2aacf4
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-tui-resume-command.md
+2026-07-21-tui-resume-command.md: c8cb855378d793a168e1f87d6b41f9a48db7dc14
+2026-07-21-tui-resume-command.zh.md: 2a7e74d1106499cb7d7232dc13954ae126246550
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md
index 86f62e16f5..c8cb855378 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md
@@ -10,17 +10,17 @@ The original `/resume` printed shell commands. It did not let a keyboard user in
## Decision
-`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled.
+`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace scope line, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It orders candidates by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. The picker opens on the current workspace and reaches every other one through the scope toggle the [cross-workspace resume](2026-07-28-cross-workspace-resume.md) note owns.
-`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process.
+`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, workspace, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process.
-After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`.
+After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume` with the validated id and the target workspace. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`.
-`resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable.
+The exit line is a launcher-owned context slot rather than a config template, and a host without in-place handoff reports that the session stays resumable instead of naming a command it cannot construct; the [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) note owns that ownership move and supersedes the `resumeCommand` config key this note originally shipped. The TUI still never executes shell text.
## Alternatives considered
-**Have the TUI spawn `resumeCommand`.** Rejected: the template is deployment text, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`.
+**Have the TUI spawn the resume command.** Rejected: the text is display copy, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`.
**Construct the resumed agent inside the existing TUI.** Rejected: replacing one config-created agent would cross Loader ownership, scoped plugin setup, persistence retirement, and terminal lifecycle in the presentation layer. Root disposal plus process replacement reuses the supported startup path.
@@ -36,4 +36,4 @@ After preflight, the TUI flushes the current session, confirms that its agent re
## Testing
-TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff.
+TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, the no-host warning, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff.
diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md
index 06e58f8144..2a7e74d110 100644
--- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md
@@ -10,17 +10,17 @@ Status: implemented
## Decision
-`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。
+`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace 作用域行、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间排列候选项,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。选择页打开时位于当前 workspace,并通过[跨 workspace 恢复](2026-07-28-cross-workspace-resume.md)记录所拥有的作用域切换到达其他每一个 workspace。
-`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。
+`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、workspace、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。
-预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。
+预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并以经过验证的 id 和目标 workspace 调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。
-`resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。
+退出时打印的行是启动器拥有的上下文插槽,而非配置模板;不支持原地交接的宿主会说明会话仍可恢复,而不再给出它无法构造的命令。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md)记录了这次所有权迁移,并取代本记录最初交付的 `resumeCommand` 配置键。TUI 仍然绝不执行 shell 文本。
## Alternatives considered
-**让 TUI 创建 `resumeCommand` 进程。** 否决:该模板是部署文本,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。
+**让 TUI 创建恢复命令进程。** 否决:该文本是展示用文案,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。
**在现有 TUI 内构造恢复后的 agent。** 否决:在表现层替换由配置创建的 agent,会跨越 Loader 所有权、作用域插件初始化、持久化资源释放和终端生命周期。释放根应用并替换进程可以复用受支持的启动路径。
@@ -36,4 +36,4 @@ Status: implemented
## Testing
-TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。
+TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、无宿主时的告警,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。
diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml
index 4532ab2148..d8ac4d0dca 100644
--- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.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-24-configurable-tui-prompt-theme.md: 4008f23a3f545e9b4484f0fa3f8490ad9b2c5541
-2026-07-24-configurable-tui-prompt-theme.zh.md: daf15b54d7e04d3860eacad47b754b963cde36fa
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md
+2026-07-24-configurable-tui-prompt-theme.md: f8815c6c1904c47ebb899c3da7ac62a50f7f88f0
+2026-07-24-configurable-tui-prompt-theme.zh.md: 831471860a9dcd8bb10408c492e1fc6299af6262
diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md
index 4008f23a3f..f8815c6c19 100644
--- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md
+++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md
@@ -16,7 +16,7 @@ The TUI theme groups `color`, `truecolor`, `leftPrompt`, `rightPrompt`, `inputPr
Registered fragments are trusted ANSI-capable presentation output. Template literals and ordinary external content remain sanitized, but a prompt-value plugin may emit terminal controls. Composite values own coordinated background transitions and separators, so one `${powerline}` value can render a complete Powerline segment without coupling adjacent atomic providers.
-The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `timing`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `timing` and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix.
+The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `queued`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `queued` — the steering-queue badge, unavailable unless a running turn has queued messages — and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md
index daf15b54d7..831471860a 100644
--- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md
@@ -16,7 +16,7 @@ TUI 主题把 `color`、`truecolor`、`leftPrompt`、`rightPrompt`、`inputPromp
注册的片段被视为可信的、允许携带 ANSI 的呈现输出。模板中的字面文本与普通外部内容仍会被清洗,但提供提示符值的插件可以输出终端控制序列。复合值自行负责协调背景色过渡与分隔符,因此一个 `${powerline}` 值就能渲染完整的 Powerline 段,而无需与相邻的原子提供方耦合。
-内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`timing`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `timing` 与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。
+内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`queued`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `queued`——转向队列徽标,仅在运行中的一轮有排队消息时才可用——与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。
## 曾考虑的替代方案
diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml
new file mode 100644
index 0000000000..cbac9d1251
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md
+2026-07-27-tmux-location-context.md: b6f0b0cc85fa4808bd761f30bdbab8ad65e61717
+2026-07-27-tmux-location-context.zh.md: e79214e03296a87c622df91437385950c00eded6
diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md
new file mode 100644
index 0000000000..b6f0b0cc85
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md
@@ -0,0 +1,61 @@
+# Agent Note: tmux-location context
+
+Status: implemented
+
+English | [中文](2026-07-27-tmux-location-context.zh.md)
+
+## Problem
+
+An agent running inside tmux has no way to tell the model where it is: which session, window, and pane the process occupies, and how the window is laid out. A user directing several panes wants the model to orient itself to its own location so instructions like "the pane below" or "this window" resolve. The location must reach the model as durable, reconstructable context, not a system-prompt value rewritten in place, and must cost nothing when the location has not changed.
+
+tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and `tmux display-message -t "$TMUX_PANE" -p ''` prints any pane/window/session field. The open question was how to observe it — pull on each preparation, or push from a tmux hook — and how to avoid a per-step token cost and hidden process-local state.
+
+## Decision
+
+`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy.
+
+**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design.
+
+**Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider.
+
+**Detect a real pane by tty, not by `$TMUX_PANE` alone.** `$TMUX_PANE` is inherited: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) carries `$TMUX`/`$TMUX_PANE` from that ancestor even though the process does not live in that pane, which otherwise injects a stale, wrong location. The command resolves this process's controlling terminal with `ps -o tty= -p ` (the agent's own pid, passed in-process) and compares it to the pane's `#{pane_tty}`; fields are emitted only on a match. A genuine pane owns this process's tty; an inherited environment names some other pane's tty and reads as "not in tmux". Checking `$TMUX` instead does not help — it is inherited identically. This is the definitive discriminator and needs no allowlist of terminal emulators.
+
+**Own location and layout only.** The queried fields are session name, window index/name, pane index/id, window/pane active flags, and `window_layout`. Pane and window pixel sizes are excluded (layout tree conveys structure; sizes are noisy and change on every terminal resize). Sibling-pane contents are never captured (`capture-pane`), keeping the reading small and avoiding scraping unrelated, possibly sensitive, output.
+
+**Inject only on change, with optional interval floor.** When due, the plugin calls `agent.inject()` for one `user/message` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression compares the rendered state block (everything after the turn preamble line) against the latest injection of this source, found by scanning raw durable session events — so the schedule survives compaction and process resume without a process-local cache. The optional `refreshIntervalMs` (manually validated as a non-negative safe integer at plugin load) additionally suppresses injections within that window of the latest one.
+
+### Text
+
+```text
+tmux location (turn ):
+session , window "", pane
+window active=<0|1>, pane active=<0|1>, layout
+```
+
+The turn preamble is the volatile first line; the two-line state block below it is the unit compared for change suppression, so re-injection is driven by tmux state, not loop position.
+
+### Durability and request reconstruction
+
+Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback.
+
+The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests.
+
+## Consequences
+
+An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. Deployments opt in through cordis.yml; the default spine and shipped examples stay silent. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one bash execution (through the sandboxed bash seam) on the first step of each turn that is due — internally a `ps` tty probe, a `tmux display-message` tty query, and the field query. Only the optional interval floor suppresses the query itself; an unchanged location is known only after querying, so it suppresses the injection alone.
+
+## Testing
+
+Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%.
+
+## Alternatives considered
+
+- **Push from a tmux hook / background watcher** — rejected: requires installing hooks in the user's tmux and a background process with teardown, to gain mid-step freshness that per-turn context does not need.
+- **Run every step** — rejected: location is stable within a turn; re-querying adds token cost without new information. Gating on `step === 1` yields per-turn readings.
+- **Raw `child_process`** — rejected: bypasses the sandbox/policy seam and hand-rolls subprocess code the `ctx.bash` executor already owns.
+- **Include pane/window pixel sizes** — rejected: sizes churn on every resize and add noise; the layout tree already conveys structure.
+- **Scrape sibling panes with `capture-pane`** — rejected: large, noisy, and privacy-sensitive; out of scope for "own location".
+- **Dynamic system-prompt section** — rejected: replacing a value erases the earlier readings behind prior reasoning and is not reconstructable; one durable attributed message records each location where it became visible.
+- **Trust `$TMUX_PANE` (or `$TMUX`) presence** — rejected: both are inherited by terminals launched from a tmux shell (VS Code integrated terminal), so a non-pane process injects a stale location. The pane `#{pane_tty}` vs. this process's controlling tty is the definitive check.
+- **Denylist known terminal emulators (e.g. `TERM_PROGRAM=vscode`)** — rejected: a partial, ever-growing list that still misses other launchers; the tty match is exact and launcher-agnostic.
+- **A runtime invariant validating each reading's turn, position, and format** — shipped initially, then removed: it re-derived the producer's own scheduling from the log and asserted a regex over text the same package had just rendered, so it restated `apply()` rather than checking an independent relation. Every failure it could report required an edit to this package, which its pipeline tests already catch. Reintroduce a companion check only for a relation the plugin does not itself compute — for example if readings gain cross-turn ordering or enclosure obligations that another package can violate.
diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md
new file mode 100644
index 0000000000..e79214e032
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md
@@ -0,0 +1,61 @@
+# Agent Note:tmux 位置上下文
+
+Status: implemented
+
+[English](2026-07-27-tmux-location-context.md) | 中文
+
+## 问题
+
+运行在 tmux 内的 agent 无法告诉模型自己身在何处:进程占据哪个 session、window、pane,以及 window 如何布局。当用户操作多个 pane 时,希望模型能对自身位置有所定位,从而让"下方的 pane""这个 window"之类的指令得以解析。位置必须以持久、可重建的上下文形式送达模型,而非在原地被改写的系统提示值,并且当位置未变化时不产生任何成本。
+
+tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所在 pane,`tmux display-message -t "$TMUX_PANE" -p ''` 可打印任意 pane/window/session 字段。待决问题在于如何观测——在每次准备时拉取,还是由 tmux hook 推送——以及如何避免逐步骤 token 成本与隐藏的进程内状态。
+
+## 决策
+
+`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。
+
+**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。
+
+**通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux/`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。
+
+**以 tty 判定真实 pane,而非仅凭 `$TMUX_PANE`。** `$TMUX_PANE` 会被继承:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程带上 `$TMUX`/`$TMUX_PANE`,即使进程并不位于那个 pane 中,否则就会注入一个陈旧且错误的位置。命令用 `ps -o tty= -p `(在进程内传入 agent 自身的 pid)解析本进程的控制终端,并与 pane 的 `#{pane_tty}` 比较;只有匹配时才输出字段。真正的 pane 拥有本进程的 tty;继承而来的环境指向的是另一个 pane 的 tty,因而被读作"不在 tmux 中"。改为检查 `$TMUX` 也无济于事——它同样会被继承。这是决定性的判别依据,且无需维护终端模拟器名单。
+
+**仅自身位置与布局。** 查询字段为 session name、window index/name、pane index/id、window/pane 活动标志以及 `window_layout`。省略 pane 与 window 像素尺寸(布局树已传达结构;尺寸嘈杂且每次终端缩放都会变化)。从不采集相邻 pane 内容(`capture-pane`),使读数保持小巧,并避免抓取无关、可能敏感的输出。
+
+**仅在变化时注入,并可选间隔下限。** 需要时,插件调用 `agent.inject()` 注入一条来源为 `{ kind: 'plugin', plugin: 'tmux-context' }` 的 `user/message`。变化抑制将渲染出的状态块(轮次前缀行之后的全部内容)与该来源的最近一次注入比较,后者通过扫描原始持久会话事件获得——因此调度可跨压缩与进程恢复存续,无需进程内缓存。可选的 `refreshIntervalMs`(在插件加载时手动校验为非负安全整数)会额外抑制距最近一次注入不足该窗口的注入。
+
+### 文本
+
+```text
+tmux location (turn ):
+session , window "", pane
+window active=<0|1>, pane active=<0|1>, layout
+```
+
+轮次前缀是易变的首行;其下的两行状态块才是变化抑制所比较的单元,因此重新注入由 tmux 状态驱动,而非循环位置。
+
+### 持久性与请求重建
+
+每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。
+
+发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。
+
+## 后果
+
+启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 bash 执行(经沙箱化的 bash seam)——内部包含一次 `ps` tty 探测、一次 `tmux display-message` tty 查询和字段查询。只有可选的间隔下限会抑制查询本身;位置是否变化只有在查询之后才知道,因此它只抑制注入。
+
+## 测试
+
+单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。
+
+## 考虑过的替代方案
+
+- **由 tmux hook / 后台监视器推送**——否决:需要在用户的 tmux 中安装 hook,并引入带清理的后台进程,只为换取按轮次上下文并不需要的步内新鲜度。
+- **每个 step 都运行**——否决:位置在一轮内稳定;重复查询只增加 token 成本而无新信息。以 `step === 1` 为门槛得到按轮次读数。
+- **裸 `child_process`**——否决:绕过沙箱/策略 seam,并手写 `ctx.bash` 执行器已拥有的子进程代码。
+- **包含 pane/window 像素尺寸**——否决:尺寸每次缩放都变动、徒增噪声;布局树已传达结构。
+- **用 `capture-pane` 抓取相邻 pane**——否决:庞大、嘈杂且涉及隐私;超出"自身位置"范围。
+- **动态系统提示区块**——否决:替换某个值会抹去支撑先前推理的历史读数且不可重建;单条持久且带来源的消息在每个位置变得可见时予以记录。
+- **信任 `$TMUX_PANE`(或 `$TMUX`)存在即可**——否决:两者都会被从 tmux shell 启动的终端(VS Code 集成终端)继承,于是非 pane 进程会注入陈旧位置。pane 的 `#{pane_tty}` 与本进程控制终端的比对才是决定性检查。
+- **对已知终端模拟器设黑名单(如 `TERM_PROGRAM=vscode`)**——否决:名单不完整且会不断增长,仍会漏掉其他启动器;tty 比对精确且与启动器无关。
+- **用运行时 invariant 校验每条读数的轮次、位置与格式**——最初随包发布,随后移除:它从日志中重新推导生产者自身的调度,并对同一个包刚刚渲染出的文本断言正则,因此只是重述 `apply()`,而非检查一条独立关系。它能报出的每种失败都必须先修改本包,而这些本包的管线测试已经覆盖。仅当出现插件自身并不计算的关系时才重新引入伴生检查——例如读数将来具备可被其他包破坏的跨轮次顺序或包裹义务。
diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml
index 820645dc95..ed6406245e 100644
--- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages 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-tui-tool-card-header.md
-2026-07-27-tui-tool-card-header.md: 0868d8dcaf5ab1641a122ecda05578aef46f5f94
-2026-07-27-tui-tool-card-header.zh.md: 71db5fc6fc853039ea9be1cb1c51686f941c4cb7
+2026-07-27-tui-tool-card-header.md: 13f5e7fec1a82d02d5bf8cae4784505c02ab6f38
+2026-07-27-tui-tool-card-header.zh.md: 85f9f1244a7da568f5dcc892729021ac0fa270c9
diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md
index 0868d8dcaf..13f5e7fec1 100644
--- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md
+++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md
@@ -24,7 +24,7 @@ The redesign is TUI-only. It touches `ToolCardComponent` in `packages/ui/tui/src
**Keep the presenter title in the header** (e.g. `Tool / read / Read src/index.ts`). Rejected: the verb duplicates the tool name, and non-bash tools have no genuinely distinct one-line description — the target belongs in the body, so only bash contributes a header desc.
-**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission.
+**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission. The body's flat default-foreground styling was later revisited: the [consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md) recesses the whole body into one dim tone under this note's colored status header.
## Consequences
diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md
index 71db5fc6fc..85f9f1244a 100644
--- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md
@@ -24,7 +24,7 @@ TUI 曾把每次工具调用渲染为 `{glyph} {title}`,其中 `title` 是 pre
**把 presenter 标题保留在表头**(例如 `Tool / read / Read src/index.ts`)。已否决:动词与工具名重复,而非 bash 工具并没有真正独立的单行描述——操作对象属于正文,因此只有 bash 向表头贡献描述段。
-**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。
+**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。正文原本以默认前景色平铺,这种样式后来也经过调整:[整合后的 TUI 呈现](../architecture/2026-07-28-consolidated-tui-presentation.md)把整个正文收进本文所述彩色状态标题之下的同一种暗色调。
## Consequences
diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml
new file mode 100644
index 0000000000..60285a26b0
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.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-28-cross-workspace-resume.md
+2026-07-28-cross-workspace-resume.md: 09b638398ea9379d39df94fcb42da3395cdd70df
+2026-07-28-cross-workspace-resume.zh.md: 5a2e7d2535c07b4ace0416b234dc28b32cbcd2fc
diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
new file mode 100644
index 0000000000..09b638398e
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
@@ -0,0 +1,52 @@
+# Agent Note: Cross-workspace session resume
+
+Status: implemented
+
+English | [中文](2026-07-28-cross-workspace-resume.zh.md)
+
+## Problem
+
+`/resume` could only reach sessions started in the launch directory, so returning to yesterday's work in another project meant remembering its path, leaving the TUI, and relaunching there. Two independent causes produced that limit, and fixing either alone changes nothing.
+
+Storage was the binding one. The shipped `tui-demo` bundle defaulted `persistenceRoot` to a relative `./.sessions`, so each launch directory owned a disjoint JSONL root and a disjoint derived `session-query.db`. Sessions from another project were not filtered out of the listing — they were absent from the store the listing reads. The JSONL backend already partitions per-cwd *inside* one root, so the partitioning was doubled: once by root, once within it.
+
+The picker then filtered again. It dropped records whose `cwd` differed from the current session before display, and `summarizeResumeCandidate` independently marked a differing `cwd` as `disabledReason: 'different workspace'`, so a foreign session that did reach the store was both hidden and refused.
+
+Finally, resume never changed directory. The host re-execs `dsh --resume=` through `process.execve`, which inherits the cwd. Session *header* cwd is restored from the log, but process cwd is what `dsh-fs-local`, the bash executor, and glob/grep resolve against, so resuming a foreign session would have replayed its transcript while acting on the wrong project.
+
+## Decision
+
+The dsh launcher supplies one session root under its Harness home through a boot slot, the picker gains a workspace scope, and the handoff carries the target directory.
+
+**Storage.** `dsh-paths` owns the location as `resolveSessionsRoot()` (`sessions` under the Harness home, by `resolveDshHome`'s precedence), but only the launcher assumes it: shared-store policy is the dsh CLI's, never a plugin's. The TUI surface provides the root through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before Loader entries mount) and `dsh web` patches the same root in `apps/cli/src/app-cli-entry.ts`. Two CLI surfaces computing that path independently is exactly the failure this change fixes — disjoint stores — so the fact gets one home rather than a `join` per caller, alongside the existing `registryRoot()` precedent for `run`.
+
+`tui-demo` itself keeps a project-local `./.sessions` default and reads the launcher slot between explicit config and that default (`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`). The precedence lives in `composeTuiApp`, not as a schemastery `.default()`, because a schema default would materialize before the compose function runs and shadow the slot for every Loader mount. `examples/tui-agent/cordis.yml` omits `persistenceRoot` so the launcher slot (or, for a bare example boot, the project-local default) applies. Configuring an explicit root always wins, which remains the correct choice for a hermetic deployment.
+
+**Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for.
+
+`summarizeResumeCandidate` therefore drops `'different workspace'` and gains `'session has no recorded workspace'`. That is a real new refusal rather than a rename: a header without `cwd` names no directory for the host to enter, so it cannot be handed off even though its log is intact.
+
+**Handoff.** `TuiResumeHost.handoff` takes the target `cwd` beside the `SessionId`. `preflightResume` resolves both together and returns them, so the caller cannot re-derive a stale directory from the row it displayed — a record whose `cwd` moved between listing and preflight is resumed in the *re-read* directory, which is why the former "reject a moved cwd" behavior is now a handoff with the new path. The shipped host chdirs before disposing the app: an unreachable directory must reject while the caller can still restore the terminal, because after teardown no owner remains to report to. `resumeArgs` keeps the `meta` subcommand form only when the target is this checkout, since `dsh meta` chdirs to the harness source itself and would override any other workspace.
+
+## Alternatives considered
+
+**Patch `persistenceRoot` from the `dsh` launcher instead of changing the bundle default.** Rejected after finding that a loader patch assigns `config` wholesale. The personal `~/.dsh/config.yaml` overlay already patches the `tui-agent` row with a partial config, which is exactly why `persistenceRoot` was falling back to the bundle default in the first place; a launcher patch would either be erased by that overlay or have to win over it and make the overlay unable to set the field. Owning the default in the bundle survives any partial patch and keeps one home for the fact.
+
+**Keep `./.sessions` and additionally scan the Harness-home root.** Rejected: two roots means two SQLite indexes and a merged listing whose rows have different liveness and revision authorities, to preserve visibility of logs that the no-migration decision already gives up.
+
+**Migrate existing project-local logs into the shared root.** Rejected by the requester. Sessions under a project's `./.sessions` stay on disk and stay resumable by explicit `dsh --resume ` from that directory, but no longer appear in `/resume`.
+
+**One flat list of every workspace.** Rejected: it loses the "this project" default that the overwhelmingly common case wants, and in a busy home directory the current project's sessions would compete with unrelated ones.
+
+**Let the host infer the directory from the restored session header.** Rejected: the header is model- and prompt-facing state restored *after* boot, while the directory must be entered *before* `execve`. Passing it explicitly keeps the ordering visible at the seam.
+
+## Consequences
+
+- Sessions already stored under a project-local `./.sessions` disappear from `/resume`. This is the accepted cost of no migration.
+- One shared root makes the pre-existing absence of a cross-process session lock reachable in one step: colliding used to require two terminals in the same directory, and is now one Tab away. `record.live` comes from the in-process `SessionQueryService`, so preflight rejects only sessions live in *this* runtime, while the JSONL backend takes no lock and two processes appending one log with independent `seq` counters would interleave. Closing this is no longer speculative hardening: `SessionRegistry.list()` already publishes live sessions cross-process under the same Harness home for `dsh list-sessions`, so consulting it in `summarizeResumeCandidate` is a small follow-up. It stays out of this change as pre-existing scope.
+- A resumed session can change the process's working directory, so a foreign resume is not a pure transcript restoration — every path-resolving tool moves with it.
+- The Harness home now holds session logs for every project on the machine. Its growth is no longer bounded by one checkout, and no retention policy is introduced here.
+
+## Testing
+
+TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. `dsh-paths` tests pin `resolveSessionsRoot`'s precedence against `resolveDshHome`'s. `tui-demo` composition tests pin the project-local default and the derived `session-query.db` path. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace.
diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md
new file mode 100644
index 0000000000..5a2e7d2535
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md
@@ -0,0 +1,52 @@
+# Agent Note: 跨 workspace 会话恢复
+
+Status: implemented
+
+[English](2026-07-28-cross-workspace-resume.md) | 中文
+
+## Problem
+
+`/resume` 只能触达在启动目录中创建的会话,因此要回到昨天在另一个项目里的工作,就得记住它的路径、退出 TUI、再到那里重新启动。造成这一限制的原因有两个,彼此独立,只修其中一个都不会有任何变化。
+
+存储是那个决定性的原因。已交付的 `tui-demo` 组合包把 `persistenceRoot` 默认成相对路径 `./.sessions`,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 `session-query.db`。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在*同一个*根目录*内部*按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。
+
+接着选择器又过滤了一次。它在展示前丢弃 `cwd` 与当前会话不同的记录,而 `summarizeResumeCandidate` 又独立地把不同的 `cwd` 标记为 `disabledReason: 'different workspace'`,于是一个确实进入了存储的外部会话既被隐藏,也会被拒绝。
+
+最后,恢复流程从不切换目录。宿主通过 `process.execve` 重新执行 `dsh --resume=`,而它会继承 cwd。会话*头部*的 cwd 会从日志中还原,但 `dsh-fs-local`、bash 执行器以及 glob/grep 解析路径时依据的是进程 cwd,所以恢复一个外部会话会在回放它的 transcript(文本记录)的同时,作用到错误的项目上。
+
+## Decision
+
+dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。
+
+**存储。** `dsh-paths` 以 `resolveSessionsRoot()` 拥有该位置(按 `resolveDshHome` 的优先级,取 Harness home 下的 `sessions`),但只有启动器假定它:共享存储策略属于 dsh CLI,绝不属于插件。TUI 界面通过 `SESSIONS_ROOT_KEY` 启动槽位(在 Loader 条目挂载前 `ctx.provide`)提供该根目录,`dsh web` 则在 `apps/cli/src/app-cli-entry.ts` 中为同一根目录打补丁。CLI 的两处界面各自独立计算该路径,正是本次改动所修复的那种失败——互不相交的存储——因此这项事实只有一个归属,而不是每个调用方各做一次 `join`,这与 `run` 已有的 `registryRoot()` 先例一致。
+
+`tui-demo` 自身保持项目本地的 `./.sessions` 默认值,并在显式配置与该默认值之间读取启动器槽位(`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`)。这一优先级放在 `composeTuiApp` 内,而不是写成 schemastery 的 `.default()`,因为 schema 默认值会在 compose 函数运行前物化,使每次 Loader 挂载都遮蔽该槽位。`examples/tui-agent/cordis.yml` 不写 `persistenceRoot`,因此启动器槽位(裸示例启动时则为项目本地默认值)生效。显式配置的根目录总是获胜,对于封闭部署来说这仍然是正确的选择。
+
+**是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'` 的 `scope`,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。
+
+因此 `summarizeResumeCandidate` 去掉了 `'different workspace'`,并新增 `'session has no recorded workspace'`。这是一条真正新增的拒绝理由,而不是改名:没有 `cwd` 的头部没有指明任何目录供宿主进入,所以即便它的日志完好也无法完成交接。
+
+**交接。** `TuiResumeHost.handoff` 在 `SessionId` 之外还接收目标 `cwd`。`preflightResume` 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 `cwd` 发生了移动的记录,会在*重新读取到的*目录中恢复,这也是原先「拒绝已移动的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在释放应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。`resumeArgs` 只在目标就是本 checkout 时才保留 `meta` 子命令形式,因为 `dsh meta` 会切换到 harness 源码本身,从而覆盖任何其他 workspace。
+
+## Alternatives considered
+
+**从 `dsh` 启动器给 `persistenceRoot` 打补丁,而不是改动组合包默认值。** 在发现 loader 补丁会整体赋值 `config` 之后否决。个人的 `~/.dsh/config.yaml` 覆盖层已经用一份局部配置给 `tui-agent` 那一项打了补丁,这恰恰就是 `persistenceRoot` 一开始会退回到组合包默认值的原因;启动器补丁要么会被该覆盖层擦除,要么必须压过它,从而让覆盖层再也无法设置这个字段。把默认值放在组合包里能经受任何局部补丁,并让这项事实只有一个归属。
+
+**保留 `./.sessions`,并额外扫描 Harness home 根目录。** 否决:两个根目录意味着两份 SQLite 索引,以及一份合并列表——其中各行的活跃状态与版本权威来源并不相同,而这一切只是为了保住不做迁移的决策本就已经放弃的那部分日志可见性。
+
+**把现有的项目本地日志迁移到共享根目录。** 被需求方否决。项目 `./.sessions` 下的会话仍留在磁盘上,从该目录显式执行 `dsh --resume ` 仍可恢复,只是不再出现在 `/resume` 中。
+
+**把所有 workspace 铺成一个扁平列表。** 否决:这会丢掉绝大多数场景想要的「本项目」默认值,而在一个繁忙的 home 目录里,当前项目的会话会和无关会话争夺注意力。
+
+**让宿主从还原后的会话头部推断目录。** 否决:会话头部是面向模型与提示词的状态,在启动*之后*才还原,而目录必须在 `execve` *之前*进入。显式传递它能让这个顺序在边界处保持可见。
+
+## Consequences
+
+- 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。
+- 同一个共享根目录让原本就缺失的跨进程会话锁一步之内即可触达:过去要造成冲突需要在同一个目录里开两个终端,如今只差一次 Tab。`record.live` 来自进程内的 `SessionQueryService`,因此预检只会拒绝在*本*运行时中处于活跃状态的会话,而 JSONL 后端不加任何锁,两个进程用各自独立的 `seq` 计数器追加同一份日志会互相交错。解决这一点已不再是投机性加固:`SessionRegistry.list()` 已经为 `dsh list-sessions` 在同一个 Harness home 下跨进程发布活跃会话,因此在 `summarizeResumeCandidate` 中查询它是一项小的后续工作。它作为既有范围之外的问题不纳入本次改动。
+- 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。
+- Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。
+
+## Testing
+
+TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。`dsh-paths` 测试固定 `resolveSessionsRoot` 的优先级与 `resolveDshHome` 的一致。`tui-demo` 组合测试固定项目本地默认值以及派生出的 `session-query.db` 路径。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。
diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml
new file mode 100644
index 0000000000..7e8ca82fae
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.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-28-dsh-guided-skill-session-commands.md
+2026-07-28-dsh-guided-skill-session-commands.md: 338629f5a1adb9c1973daf87f2f52479bd70ba47
+2026-07-28-dsh-guided-skill-session-commands.zh.md: a9a8a70212dd92b9c850a529789f4e4879838090
diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md
new file mode 100644
index 0000000000..338629f5a1
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md
@@ -0,0 +1,43 @@
+# Agent Note: `dsh migrate`/`dsh upgrade` seed the first turn with a skill
+
+Status: implemented
+
+English | [中文](2026-07-28-dsh-guided-skill-session-commands.zh.md)
+
+## Problem
+
+Two recurring flows begin with the user manually invoking one skill and answering its questions: migrating from another coding agent, and upgrading this checkout. Both require the user to know the skill exists and to type `/skill:dsh-migrate` or `/skill:dsh-upgrade` as the session's first turn. A dedicated entry command that drops the user straight into that guided session removes the discovery step.
+
+## Decision
+
+`dsh migrate` and `dsh upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:` and pressed Enter.
+
+The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:` runs, including the "Unknown skill" notice. The launcher passes the skill name to the app through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set.
+
+**Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume ` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup.
+
+**`migrate`/`upgrade` take no options.** Unlike `meta`, they carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`.
+
+The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED_SKILL_DIR`, like `dsh-upgrade`). It asks which source agent (opencode/pi/Claude Code/Codex) if unstated, then maps each capability — workspace instructions, personal overlay, skills, hooks, MCP, API/env — to its DSH equivalent, grounded in the actual repo surfaces (the `hooks-claude`/`hooks-codex` bridges, `~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`, `AGENTS.md`/`CLAUDE.md`, `mcporter`), and states plainly when a capability has no equivalent.
+
+## Testing
+
+`apps/cli/tests/args.spec.ts` gains routing for `migrate`/`upgrade` (bare discriminant) and exit-1 for every leaked option on either side of each subcommand.
+
+`packages/ui/tui/tests/tui.spec.ts` gains two fake-terminal cases in the existing skill describe block: `config.initialSkill` set delivers the rendered skill body as the first turn with no user input, and an unknown initial skill reports a notice without sending. `runSkillSession` itself is composition inside the module's `v8 ignore` block, like `runTui`/`runMeta`.
+
+No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery.
+
+## Alternatives considered
+
+**Prefill the input and let the user press Enter.** Rejected: needs a new editor-prefill seam and still requires a keystroke. Auto-submit reuses `invokeSkill` and delivers the intended one-command entry.
+
+**Seed a natural-language instruction ("use the dsh-migrate skill…") instead of `/skill:`.** Rejected here: the literal skill-invocation path renders the skill body into the first turn deterministically, identical to the manual command, rather than depending on the model choosing to load the skill.
+
+**Support `--resume` on `migrate`/`upgrade`.** Rejected: these are one-shot guided entries. A resumed session is an ordinary TUI session reachable through the default surface's `dsh --resume `; re-injecting the skill on resume would duplicate the first turn.
+
+**Read `INITIAL_SKILL_KEY` in the app bundle (like `MAIN_SESSION_ID_KEY`) rather than in the TUI's `apply()`.** Not needed: `initialSkill` is a TUI `Config` field consumed in `createTuiChat`, so folding the slot into config at the TUI entry keeps it beside the other launcher-owned runtime reads (`tuiResumeHost`, `tuiGoodbyeMessage`) and leaves the app bundle unchanged.
+
+## Consequences
+
+Migrating or upgrading is one command from anywhere, with the guiding skill already invoked. The launcher→TUI initial-skill slot is reusable by any future guided-session command; the TUI's contract is "invoke this named skill once at startup," and freshness/resume policy stays with the launcher that owns session identity. The [TUI skill slash command](2026-07-21-tui-skill-slash-command.md) remains the mechanism; this note adds a launcher-driven auto-invocation of it and does not supersede it.
diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md
new file mode 100644
index 0000000000..a9a8a70212
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md
@@ -0,0 +1,43 @@
+# Agent Note:`dsh migrate`/`dsh upgrade` 以 skill 播种首轮
+
+Status: implemented
+
+[English](2026-07-28-dsh-guided-skill-session-commands.md) | 中文
+
+## 问题
+
+有两个反复出现的流程都以用户手动调用某个 skill 并回答其问题开始:从其他编码 agent 迁移,以及升级本 checkout。二者都要求用户知道该 skill 存在,并把 `/skill:dsh-migrate` 或 `/skill:dsh-upgrade` 作为会话首轮键入。一个专用入口命令若能让用户直接进入该引导式会话,便可省去这一发现步骤。
+
+## 决策
+
+`dsh migrate` 与 `dsh upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:` 并回车。
+
+播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给应用,与 `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。
+
+**新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。
+
+**`migrate`/`upgrade` 不接受任何选项。** 与 `meta` 不同,它们不带 `--resume`、`--config` 或 `-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'`);`bin.ts` 将 mode 映射为 `dsh-${mode}`。
+
+`dsh-migrate` skill 内置于 `skills/`(经 `DSH_BUNDLED_SKILL_DIR` 交付,与 `dsh-upgrade` 相同)。若未说明源 agent,它会先询问是哪个(opencode/pi/Claude Code/Codex),再把每项能力——workspace 指令、个人覆盖、skills、hooks、MCP、API/env——映射到对应的 DSH 等价物,并基于仓库实际的表面(`hooks-claude`/`hooks-codex` 桥、`~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`、`AGENTS.md`/`CLAUDE.md`、`mcporter`)落地;当某能力无等价物时明确说明。
+
+## 测试
+
+`apps/cli/tests/args.spec.ts` 新增 `migrate`/`upgrade` 的路由(裸判别式),以及每个子命令两侧任一泄漏选项的退出码 1。
+
+`packages/ui/tui/tests/tui.spec.ts` 在既有 skill describe 块中新增两个伪终端用例:设置 `config.initialSkill` 时无需用户输入即把渲染后的 skill 正文作为首轮投递;未知的初始 skill 以通知形式报告且不发送。`runSkillSession` 本身是模块 `v8 ignore` 块内的组装,与 `runTui`/`runMeta` 相同。
+
+无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent;`dsh upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。
+
+## 考虑过的替代方案
+
+**预填输入框并让用户按回车。** 已否决:需要新增编辑器预填 seam,且仍需一次按键。自动提交复用 `invokeSkill`,实现预期的一命令入口。
+
+**播种自然语言指令(“使用 dsh-migrate skill……”)而非 `/skill:`。** 在此否决:字面 skill 调用路径会确定性地把 skill 正文渲染进首轮,与手动命令完全一致,而不依赖模型自行选择加载该 skill。
+
+**在 `migrate`/`upgrade` 上支持 `--resume`。** 已否决:它们是一次性引导入口。恢复的会话是可经默认界面 `dsh --resume ` 到达的普通 TUI 会话;恢复时重新注入 skill 会重复首轮。
+
+**在应用 bundle 中读取 `INITIAL_SKILL_KEY`(像 `MAIN_SESSION_ID_KEY` 那样)而非在 TUI 的 `apply()` 中。** 无此必要:`initialSkill` 是在 `createTuiChat` 中消费的 TUI `Config` 字段,因此在 TUI 入口处把该槽折叠进 config,可与其他启动器拥有的运行时读取(`tuiResumeHost`、`tuiGoodbyeMessage`)并列,且无需改动应用 bundle。
+
+## 后果
+
+迁移或升级从任何位置都只需一条命令,且引导 skill 已被调用。启动器→TUI 的初始 skill 槽可被未来任何引导式会话命令复用;TUI 的契约是“在启动时调用一次这个具名 skill”,而新鲜性/恢复策略留在拥有会话身份的启动器一侧。[TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)仍是该机制;本 note 在其之上新增了一个由启动器驱动的自动调用,并未取代它。
diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml
new file mode 100644
index 0000000000..1a7c941216
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.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-28-dsh-meta-source-workspace.md
+2026-07-28-dsh-meta-source-workspace.md: d65e0e6ff092b63931dd58c52fa76fb76a071dff
+2026-07-28-dsh-meta-source-workspace.zh.md: 72a0c65e6eeda7d63dccc2306a3d7625cdb54362
diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md
new file mode 100644
index 0000000000..d65e0e6ff0
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md
@@ -0,0 +1,49 @@
+# Agent Note: `dsh meta` boots the TUI over the harness checkout
+
+Status: implemented
+
+English | [中文](2026-07-28-dsh-meta-source-workspace.zh.md)
+
+## Problem
+
+`dsh` treats the invoking directory as the workspace, which is what makes it useful on arbitrary projects. Working on dsh itself therefore means `cd`-ing to the checkout first — and the checkout is not a memorable path: the source install keeps it under a container directory as a timestamped staging worktree (`~/.dsh/source/staging-`) behind a `current` symlink, so the target moves on every upgrade. The agent is already *told* where its source lives by the `harness:source` prompt section, and the `cordis` toolset can modify that runtime, but the human still had to locate the directory by hand to start a session there.
+
+## Decision
+
+`dsh meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory.
+
+The target is `SOURCE_ROOT` in `apps/cli/src/tui.ts` — `fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — the same constant the `harness:source` prompt section already names, so the workspace and the path advertised to the model cannot drift. It follows the launcher's real path, so a PATH symlink through `current` resolves to whichever staging worktree is active.
+
+The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by a new optional third parameter that only `runMeta` passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent.
+
+`meta` accepts only `--resume `. `--config` would boot a foreign tree against the harness workspace, which is the `--config` case rather than this one; `-p` is not interactive. Both fail loud, as does an empty `--resume=` — matching the default surface, where a swallowed empty id would silently start a fresh session.
+
+**`meta` does not redeclare `--resume`.** Commander parses an option a subcommand shares with its parent into `program.opts()` and leaves the subcommand's own options object empty, so redeclaring it silently dropped the id (found by probing the adapter, not by review). The action reads `program.opts()`, which also accepts the flag on either side of the subcommand; `--help` still lists it among the parent's options.
+
+## Testing
+
+`apps/cli/tests/args.spec.ts` extends its two existing cases rather than adding a file: routing for `meta`, `meta --resume `, and `--resume meta` (pinning the shared-option behavior above), and exit-1 for `meta --resume=`, `meta --config`, and `meta -p`. `runMeta` itself is composition inside the module's existing `v8 ignore` block, like `runTui`.
+
+There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir.
+
+The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool call reports the checkout, git resolves to its branch, the session log lands under the checkout's `.sessions/` (leaving `~/.sessions` untouched and the tree free of unignored residue), and plain `dsh` from another directory still uses the invoking one.
+
+`dsh meta --resume ` once started a *fresh* session instead of resuming — a pre-existing defect on the default surface, not one this mode introduced. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) found the cause and fixed it: a personal overlay had replaced the whole `tui-agent` config block, overwriting the shipped `resumeSessionId` intake with a read of an unset environment variable, so a valid id was silently ignored. Session identity is now a launcher-owned context slot that no config key can displace, and `meta` routes through it.
+
+## Alternatives considered
+
+**Thread an explicit workspace through `boot` and the config tree.** Avoids mutating process-wide state, but the shipped config reads the cwd in three places (`!!js process.cwd()`, `persistenceRoot`, HMR `root`), so each would need its own new plumbing and config key to stay consistent. `chdir` before boot expresses "this is the workspace" once, at the seam that already means it.
+
+**A `--meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `meta` alongside `web` matches the existing shape.
+
+**Resolve `~/.dsh/source/current` instead of the launcher's own path.** Rejected: it would diverge from the `harness:source` prompt path whenever a non-installed checkout's `bin/dsh` is invoked directly, telling the model one source root while working in another.
+
+**Make the printed resume hint mode-aware.** Deferred here as a known cost, then delivered by [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md): the exit line became a launcher-provided context slot, so meta mode prints `dsh meta --resume ` and a copied hint works from any directory. It previously came from static config as `dsh --resume {session}` and only worked when re-run from the checkout.
+
+## Consequences
+
+Starting a session on dsh's own source is `dsh meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. Meta sessions are isolated in the checkout's `.sessions/`, so `dsh meta --resume` sees only other meta sessions — intended, since a session's logged cwd belongs to its workspace.
+
+The resume hint was this mode's original cost and is now resolved. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) made both the printed line and the in-place `/resume` handoff reproduce the mode as `dsh meta --resume ` from one shared argv helper, so a copied hint works from any directory and the handoff no longer depends implicitly on `execve` preserving the process cwd.
+
+`runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it.
diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md
new file mode 100644
index 0000000000..72a0c65e6e
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md
@@ -0,0 +1,49 @@
+# Agent Note:`dsh meta` 以 harness 检出为 workspace 启动 TUI
+
+Status: implemented
+
+[English](2026-07-28-dsh-meta-source-workspace.md) | 中文
+
+## Problem
+
+`dsh` 把调用目录视为 workspace,这正是它能作用于任意项目的原因。但因此,开发 dsh 自身就得先 `cd` 到检出目录——而该目录并不是一个好记的路径:源码安装会把它放在一个容器目录下、作为带时间戳的 staging 工作树(`~/.dsh/source/staging-`),并由 `current` 符号链接指向,因此每次升级后目标都会变化。`harness:source` 提示词段已经*告知* agent 其源码位置,`cordis` 工具集也能修改该运行时,但人类仍需手工定位该目录才能在其中开始会话。
+
+## Decision
+
+`dsh meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。
+
+目标是 `apps/cli/src/tui.ts` 中的 `SOURCE_ROOT`——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——与 `harness:source` 提示词段所用的常量完全相同,因此 workspace 与告知模型的路径不可能发生偏离。它跟随启动器的真实路径,所以经由 `current` 的 PATH 符号链接会解析到当前生效的那个 staging 工作树。
+
+机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个新的可选第三参数把守,只有 `runMeta` 会传入。在已交付的配置树中,cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd(`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动,meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG` 与 `SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode,所以配置解析与 chdir 无关。
+
+`meta` 只接受 `--resume `。`--config` 会以 harness workspace 启动其他配置树,那属于 `--config` 的场景而非本场景;`-p` 并非交互式。两者都会明确报错,空的 `--resume=` 亦然——与默认界面一致,在那里被吞掉的空 id 会静默开启一个新会话。
+
+**`meta` 不重新声明 `--resume`。** 对于子命令与父命令共享的选项,Commander 会将其解析进 `program.opts()`,而把子命令自身的 options 对象留空;因此重新声明会静默丢弃该 id(这是通过实测适配器发现的,而非评审发现)。action 读取 `program.opts()`,这同时也允许该标志出现在子命令的任意一侧;`--help` 仍会在父命令的选项中列出它。
+
+## Testing
+
+`apps/cli/tests/args.spec.ts` 扩展其已有的两个用例而非新增文件:`meta`、`meta --resume ` 与 `--resume meta` 的路由(钉住上述共享选项行为),以及 `meta --resume=`、`meta --config`、`meta -p` 的退出码 1。`runMeta` 自身与 `runTui` 一样,属于该模块既有 `v8 ignore` 块内的组合代码。
+
+该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd,但 `dsh meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam,本 note 不予采纳。
+
+取而代之的是交互式验证。从 `$HOME` 启动后,`pwd` 工具调用报告的是该检出目录,git 解析到其分支,会话日志落在该检出的 `.sessions/` 下(`~/.sessions` 未被触及,工作树也没有未被忽略的残留),并且从其他目录运行的普通 `dsh` 仍使用调用目录。
+
+`dsh meta --resume <有效 id>` 曾经开启一个*新*会话而非恢复——这是默认界面上既已存在的缺陷,并非本 mode 引入。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 查明了原因并将其修复:一个个人 overlay 替换了整个 `tui-agent` 配置块,用对一个未设置的环境变量的读取覆盖了已交付的 `resumeSessionId` 入口,因此有效的 id 会被静默忽略。会话标识如今是一个启动器拥有的上下文槽位,没有任何配置键能取代它,而 `meta` 经由它进行路由。
+
+## Alternatives considered
+
+**通过 `boot` 与配置树显式传递 workspace。** 这可避免修改进程级状态,但已交付的配置在三处读取 cwd(`!!js process.cwd()`、`persistenceRoot`、HMR `root`),每一处都需要各自新增管线与配置键才能保持一致。启动前 chdir 只在本就表达该含义的接缝上表达一次“这就是 workspace”。
+
+**在默认界面上加一个 `--meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`meta` 与 `web` 并列符合既有形态。
+
+**解析 `~/.dsh/source/current` 而非启动器自身路径。** 拒绝:当直接调用某个非安装检出的 `bin/dsh` 时,它会与 `harness:source` 提示词路径产生偏离——告知模型一个源码根目录,却在另一个目录中工作。
+
+**让打印的恢复提示随 mode 变化。** 在此作为已知代价推迟,随后由 [由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 交付:退出行变成了一个启动器提供的上下文槽位,因此 meta 模式打印 `dsh meta --resume `,被复制的提示在任意目录下都有效。它此前来自静态配置,固定为 `dsh --resume {session}`,且只有在检出目录中重新运行才有效。
+
+## Consequences
+
+在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta`,且该 workspace 必然就是告知模型的那个检出目录。meta 会话被隔离在检出目录的 `.sessions/` 内,因此 `dsh meta --resume` 只能看到其他 meta 会话——这是预期行为,因为会话记录的 cwd 属于它的 workspace。
+
+恢复提示曾是本 mode 的原初代价,如今已解决。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 让打印的行与原地 `/resume` 移交都从同一个共享的 argv 辅助函数将该 mode 复现为 `dsh meta --resume `,因此被复制的提示在任意目录下都有效,且移交不再隐式依赖于 `execve` 保留进程 cwd。
+
+`runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。
diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml
new file mode 100644
index 0000000000..eb4893717e
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.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-28-live-session-registry-and-dsh-ls.md
+2026-07-28-live-session-registry-and-dsh-ls.md: 02343c83ccee7b67e3b3e4c72de842415d4a9f6e
+2026-07-28-live-session-registry-and-dsh-ls.zh.md: 722ac45f2eb07256f196d2828b8969ae9f4965b8
diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md
new file mode 100644
index 0000000000..02343c83cc
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md
@@ -0,0 +1,65 @@
+# Agent Note: live-session registry and `dsh list-sessions`
+
+Status: implemented
+
+English | [中文](2026-07-28-live-session-registry-and-dsh-ls.zh.md)
+
+## Problem
+
+Nothing could answer "which dsh sessions am I running right now". A user with sessions across several projects had no way to enumerate them, and no way to recover the id needed for `--resume` except the exit line of the terminal that printed it. Session persistence records every session that ever existed, so it cannot answer the question: it has no notion of liveness, and no `process.pid` appeared anywhere in the session, persistence, or storage packages.
+
+## Decision
+
+`dsh list-sessions` (alias `dsh ps`) lists the sessions running right now — session id, pid, uptime, workspace, title — newest first, across every workspace, with `--json` for machines. Three packages back it, as a capability seam.
+
+[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md) (`ctx.sessionRegistry`) is the seam: the abstract service contract and record vocabulary, so the medium can later move to a database without touching consumers. [`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) implements it over one lock-guarded JSON file under the Harness home. [`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) follows `session/created`, `session/disposed`, and `session/title` and keeps the registry in step. `apps/cli` mounts both on every launcher surface — the TUI, `dsh meta`, headless, and web — and `dsh list-sessions` mounts only the service, booting no agent tree. No surface label is recorded: a launcher's mode is not a property of the session, and the workspace column already distinguishes a `dsh meta` session from a project one.
+
+### Liveness is derived, never stored
+
+`list()` probes each record's pid with `kill(pid, 0)` and drops the dead ones, writing the pruned result back. A process killed without running its disposer leaves a record that the next read removes, so there is no daemon, no heartbeat, and no permanent phantom. A per-process `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record from a different incarnation. `EPERM` counts as alive: a live session owned by another user must not be dropped.
+
+### Two independent concurrency layers
+
+The file is written by every dsh process and by several sessions inside one process, and the two cases need different mechanisms.
+
+Across processes, each read-modify-write holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Within one process, calls queue on an internal chain, because the advisory lock is tracked per process: overlapping same-process callers contend for its bounded retry budget rather than queueing, and past roughly a dozen concurrent calls that budget runs out and a registration rejects. Since publication is fire-and-forget, such a rejection silently drops a live session from the listing — the exact "listing that lies" failure this feature exists to avoid. Both layers are load-bearing and each is pinned by a test that fails without it.
+
+### Records carry their own title
+
+The title is the one mutable field, replaced through `retitle` as `session/title` events arrive. It lives in the record rather than being read from the session log because the log's location, format, and compression are per-deployment backend choices: the TUI writes project-local zstd-compressed JSONL, the web and headless surfaces write to a global root, a user profile overrides either, and SQLite has no per-session file at all. An independent reader cannot portably parse that, so `dsh list-sessions` opens no log and assumes no backend.
+
+### Subagents are invisible by construction
+
+Only top-level launcher surfaces mount the publisher. In-process subagents (`spawn`, `fork`) have no process of their own, and the out-of-process backends spawn `dsh-jsonrpc-agent` rather than this CLI. No filter flag is needed, and no subagent package changed.
+
+## Alternatives considered
+
+**One file per session under `~/.dsh/run/`.** No lock at all, since each process only writes and deletes its own file. Rejected in favour of the single file the user chose, which then made a real advisory lock mandatory rather than optional.
+
+**A domain over the `storage-json` backend.** The obvious reuse, and wrong: that backend documents "no cross-process write locking … last write wins" and names single-host-process as its assumption, and the [domain KV storage note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) puts multi-process explicitly out of scope. A registry written concurrently by every launcher is precisely that excluded case. Widening the backend's contract would have changed a shipped guarantee for one consumer; a separate package owns the multi-process medium instead.
+
+**Hand-rolled `O_EXCL` lock directory.** Rejected under the [dependencies-over-hand-rolling policy](../process/2026-07-26-dependencies-over-hand-rolling.md): stale-lock detection, retry backoff, and compromise handling are exactly the surface a maintained dependency should own.
+
+**Accept last-write-wins on the single file.** Cheapest to build, and it silently omits real running sessions when two start close together. A listing tool that lies is worse than no listing tool.
+
+**Read the title from the session log in `dsh list-sessions`.** Implemented first, then verified live: the shipped TUI writes `session.jsonl.zstd`, whose frame helpers are internal to the jsonl backend. Exporting them would have hard-coded one backend's file format into the CLI and still shown nothing for SQLite.
+
+**Register the web server itself with a placeholder session id.** `dsh web` owns no session — its sessions are created later by browser clients — so a server row would have put a fake id in a session table. Following session lifecycle instead makes browser sessions appear and disappear as they are opened, which also subsumed the TUI's launcher-side registration and deleted that separate path.
+
+**A `--here`/`--workspace` filter.** Dropped on request: the listing is always global, and narrowing is the user's `grep`.
+
+## Consequences
+
+The registry is an observability aid, so every write is best-effort: a registry fault warns and never fails a working agent session. The cost is that a listing can lag reality by one failed write, healed by the next.
+
+Title mirroring costs one locked read-modify-write per revision, so an aggressive retitling cadence pays that write each time.
+
+`bootId` bounds pid reuse only for records this process wrote. A foreign record whose pid the operating system has reassigned to an unrelated live process is reported alive until its owner removes it — accepted because the portable alternative, reading real process start times, is `/proc`-only.
+
+Liveness is pid existence, not health: a hung process still lists as running. The registry deliberately makes no progress judgement.
+
+## Testing
+
+Unit coverage pins durable-format validation (torn text, foreign version, per-row damage that must not hide siblings), pid pruning against a genuinely reaped pid, `EPERM`-is-alive, incarnation-scoped deregistration, and `retitle` scoping. Both concurrency layers have a regression test verified to fail when its mechanism is removed: 8 real processes for the cross-process lock, 24 overlapping in-process calls for the chain. The publisher is tested over the real `SessionStore` rather than a hand-built emitter, because publication depends on the store's actual lifecycle dispatch.
+
+Verified live in tmux against the assembled application: two concurrent TUI sessions in different workspaces both listed, a title appeared after the first turn, clean exit deregistered, and `SIGKILL` left a stale record that the next `dsh list-sessions` pruned and durably rewrote.
diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md
new file mode 100644
index 0000000000..722ac45f2e
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md
@@ -0,0 +1,65 @@
+# Agent Note: 活跃会话注册表与 `dsh list-sessions`
+
+Status: implemented
+
+[English](2026-07-28-live-session-registry-and-dsh-ls.md) | 中文
+
+## 问题
+
+没有任何东西能回答「我此刻正在运行哪些 dsh 会话」。会话散落在多个项目中的用户既无法枚举它们,也无法找回 `--resume` 所需的 id,唯一的来源是打印过它的那个终端的退出行。会话持久化记录了曾经存在过的每个会话,因此它答不了这个问题:它没有存活状态的概念,而且 session、persistence、storage 这几个包里任何位置都没有出现过 `process.pid`。
+
+## 决策
+
+`dsh list-sessions`(别名 `dsh ps`)列出此刻正在运行的会话(会话 id、pid、运行时长、工作区、标题),最新的排在最前,覆盖所有工作区,并提供面向机器的 `--json`。背后由三个包(package)以能力 seam 的形式支撑。
+
+[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md)(`ctx.sessionRegistry`)是 seam:抽象服务契约与记录词汇,使介质将来可以换成数据库而不触及消费方。[`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) 在 Harness home 下的一个加锁保护的 JSON 文件上实现它。[`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) 跟随 `session/created`、`session/disposed` 和 `session/title`,让注册表保持同步。`apps/cli` 在每个启动方接口(TUI、`dsh meta`、headless、web)上都挂载这两个包,而 `dsh list-sessions` 只挂载该服务,不启动任何 agent(智能体)树。不记录任何接口标签:启动方的模式并不是会话的属性,而工作区那一列已经能把 `dsh meta` 会话和项目会话区分开。
+
+### 存活状态是推导出来的,绝不存储
+
+`list()` 用 `kill(pid, 0)` 探测每条记录的 pid,剪除已消亡的记录,并把剪除后的结果写回。未运行 disposer(资源释放)就被杀掉的进程留下的记录,会被下一次读取移除,因此不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。每个进程独有的 `bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。`EPERM` 算作存活:归属于另一个用户的存活会话绝不能被丢掉。
+
+### 两层相互独立的并发机制
+
+该文件既被每个 dsh 进程写入,也被同一进程内的多个会话写入,这两种情形需要不同的机制。
+
+跨进程时,每次读-改-写都持有一个 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。进程内则由各次调用在内部链上排队,因为咨询锁是按进程跟踪的:同一进程中重叠的调用方会争抢它有界的重试预算,而不是排队等待;大约超过十几次并发调用后,该预算耗尽,某次注册就会被拒绝。由于发布采用 fire-and-forget 方式,这样一次拒绝会静默地把一个存活会话从列表中丢掉——而这正是本功能要避免的「列表说谎」故障。两层机制都是必需的,且各有一个测试固定它:移除该机制,对应测试就会失败。
+
+### 记录自带标题
+
+标题是唯一的可变字段,随 `session/title` 事件到达,通过 `retitle` 替换。它存放在记录里,而不是从会话日志读取,因为日志的位置、格式和压缩都是逐部署的后端选择:TUI 写入项目本地的 zstd 压缩 JSONL,web 与 headless 界面写入全局根目录,用户配置文件可以覆盖二者,而 SQLite 根本没有逐会话的文件。独立读取方无法以可移植的方式解析这些内容,因此 `dsh list-sessions` 不打开任何日志,也不假定任何后端。
+
+### subagent 在设计上就不可见
+
+只有顶层启动方接口才挂载发布方。进程内 subagent(`spawn`、`fork`)没有自己的进程,而进程外后端 spawn 的是 `dsh-jsonrpc-agent` 而不是本 CLI(命令行界面)。不需要任何过滤开关,也没有改动任何 subagent 包。
+
+## 考虑过的替代方案
+
+**在 `~/.dsh/run/` 下每个会话一个文件。** 完全不需要锁,因为每个进程只写入和删除自己的文件。不予采纳,改用用户选定的单文件方案,而这也使真正的咨询锁从可选变为必需。
+
+**在 `storage-json` 后端之上做一个 domain。** 这是最显而易见的复用,但它是错的:该后端明确记载「无跨进程写锁……最后写入者胜出」,并把单一宿主进程列为自身前提,而[domain KV 存储 note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)明确把多进程排除在范围之外。被每个启动方并发写入的注册表恰恰就是这个被排除的场景。放宽该后端的契约,等于为一个消费方改动一项已上线的保证;改由一个独立的包拥有这套多进程介质。
+
+**手写 `O_EXCL` 锁目录。** 依据[优先使用依赖而非手写政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳:陈旧锁检测、重试退避和受损处理,恰恰是应当由一个有人维护的依赖拥有的那部分工作。
+
+**在单文件上接受最后写入者胜出。** 这是最省事的实现,但当两个会话相近时间启动时,它会静默漏掉真实运行中的会话。一个会说谎的列表工具比没有列表工具更糟。
+
+**在 `dsh list-sessions` 中从会话日志读取标题。** 该方案先落地实现,随后经实机验证否决:上线的 TUI 写入 `session.jsonl.zstd`,其帧处理辅助函数是 jsonl 后端的内部实现。把它们导出,等于把某一个后端的文件格式硬编码进 CLI,而且对 SQLite 仍然什么都显示不出来。
+
+**用占位会话 id 注册 web 服务器本身。** `dsh web` 不拥有任何会话(它的会话由浏览器客户端稍后创建),因此一行服务器记录会把一个假 id 放进会话表。改为跟随会话生命周期后,浏览器会话会随打开与关闭而出现和消失,这同时也涵盖了 TUI 启动方一侧的注册,并删除了那条独立路径。
+
+**加一个 `--here`/`--workspace` 过滤开关。** 按要求放弃:列表始终是全局的,收窄范围交给用户自己的 `grep`。
+
+## 后果
+
+注册表是一项可观测性辅助设施,因此每次写入都是尽力而为:注册表故障只发出警告,绝不让正常工作的 agent 会话失败。代价是列表可能因一次失败的写入而落后于现实一步,并由下一次写入修复。
+
+标题镜像每次修订都要付出一次加锁的读-改-写,因此改名节奏激进时,每次改名都要付出这一次写入。
+
+`bootId` 只对本进程写入的记录约束 pid 复用。如果一条外来记录的 pid 已被操作系统重新分配给一个无关的存活进程,那么在其所有者移除它之前,该记录会被报告为存活——之所以接受,是因为可移植的替代方案(读取进程真实启动时间)仅在 `/proc` 上可用。
+
+存活状态只表示 pid 存在,不表示健康:挂死的进程仍会被列为正在运行。注册表刻意不对进展作出判断。
+
+## 测试
+
+单元覆盖固定了持久格式校验(截断文本、外来版本、不得遮蔽同级记录的单条损坏)、针对真正已回收 pid 的剪除、`EPERM` 算存活、按 incarnation 限定范围的注销,以及 `retitle` 的作用范围。两层并发机制各有一个回归测试,且都已验证在移除对应机制后会失败:跨进程锁用 8 个真实进程,进程内链用 24 次重叠调用。发布方在真实的 `SessionStore` 上测试,而非手搭的事件发射器,因为发布依赖该 store 实际的生命周期派发。
+
+已在 tmux 中针对组装后的应用实机验证:位于不同工作区的两个并发 TUI 会话都被列出,第一轮之后出现标题,正常退出完成注销,而 `SIGKILL` 留下的陈旧记录被下一次 `dsh list-sessions` 剪除并持久重写。
diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml
new file mode 100644
index 0000000000..91227d80e1
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.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-28-source-guard-staging-edit-gate.md
+2026-07-28-source-guard-staging-edit-gate.md: 8452006190166c783efafc398566ef7f4da10323
+2026-07-28-source-guard-staging-edit-gate.zh.md: 83589ce833e4aa74968b40247848685a1e030f6b
diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md
new file mode 100644
index 0000000000..8452006190
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md
@@ -0,0 +1,76 @@
+# Agent Note: source-guard denies direct staging-checkout edits
+
+Status: implemented
+
+English | [中文](2026-07-28-source-guard-staging-edit-gate.zh.md)
+
+## Problem
+
+The [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill governs every personal change to a dsh source checkout: implement in a task worktree branched from the staging tip, then integrate under `.agents/merge.lock`. Its central rule is negative — do not edit the personal staging checkout directly — and a negative rule delivered only as prompt text fails in exactly the case that matters. An agent that never loads the skill never sees the rule, and one that loads it early can still forget it thirty tool calls later. The failure is silent and expensive: commits land on the staging branch that the launcher runs from, outside any task branch, with no lock held and no rollback worktree.
+
+Prompt guidance cannot fix this, because the guidance is what went unread. The rule needs an enforcement point.
+
+## Decision
+
+`@deepseek-ai/dsh-source-guard` (`packages/guard/source-guard/`) is a `tools/pre-execute` listener that returns `{kind: 'deny', reason}` for a `write` or `edit` whose target resolves inside a protected staging worktree, unless the calling session's durable log already records a successful `skill` call naming `dsh-customize`. It registers no service and contributes no prompt text or tool schema; an allowed call is indistinguishable from one made without the plugin. It is not in any shipped default composition.
+
+### Git identity from files, not a path prefix and not `git`
+
+Whether a path is protected is decided by reading `.git`, its `gitdir:` pointer, and `HEAD`. Three shapes resolve: a plain clone (`.git` is a directory that is its own common dir), a linked worktree (`.git` is a file pointing at `/worktrees/`, whose common dir is two levels up), and a detached HEAD (`HEAD` holds a raw object id and names no branch). A `gitdir:` pointer resolves whether absolute — what `git worktree add` writes — or relative, which git resolves against the worktree directory holding it.
+
+Denial requires the target's worktree to match the launcher's on both identities: the same shared git directory and the same branch. Both come from resolving `protectedCheckout`, so nothing about the protected branch is configured. An earlier revision matched a `dsh-staging/*` name pattern instead; the exact-branch rule replaced it because a pattern is wrong in both directions. It denied every sibling staging worktree an old install had left behind, none of which runs a launcher, and it silently protected nothing for a maintainer whose staging branch follows no naming convention — a fatal property for a shipped default that must hold for checkouts [`scripts/install.sh`](../../../../scripts/install.sh) did not create.
+
+A path-prefix rule would have been wrong, not merely imprecise. The task worktrees the skill prescribes live *inside* the protected tree at `/.worktrees/...`, so a prefix rule would deny every edit the workflow requires. Resolution walks outward from the target and stops at the first enclosing worktree, so it reports the innermost one: a nested task worktree answers with its own task branch and is allowed, while the launcher's own tree answers with the launcher's branch and is denied.
+
+Two path details decide whether the gate holds at all, and both are enforcement, not polish. Repository identity is compared on symlink-resolved paths (`canonicalPath` from `dsh-sandbox`), because a session cwd under `/var/...` and a configured path under `/private/var/...` are the same macOS directory and a lexical comparison would fail open on every write. And a relative `file_path` is resolved against the calling session's workspace, exactly as `dsh-tool-fs` resolves it; judging only absolute paths would have left a relative path as an unguarded route to a protected file.
+
+`protectedCheckout` names a path inside the guarded checkout, defaulting to this module's own file. That resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. A harness running from an installed copy resolves a different repository, or none, and guards nothing; the rule is meaningless outside a source checkout.
+
+The shipped TUI composition loads the plugin with these defaults, so every source install is protected without configuration. It is inert for an ordinary project: a workspace in another repository, or none, never matches the launcher's identities.
+
+### Satisfaction replayed from the durable log
+
+The gate lifts on a `tool/call` naming the `skill` tool whose arguments parse to `{name: }`, paired by call id with a non-error `tool/result`. Both fields are already durable (`packages/core/session/src/types.ts`), so this needs no new session event and no coupling to skill-provider internals.
+
+The log is the only state. In-memory satisfaction (the `WeakMap` shape [`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) uses for its chains) would be smaller, but it loses satisfaction on resume: a resumed session that already read the skill would be told to read it again, and the denial would look like a bug rather than a rule. Replay costs a scan bounded by the first hit and buys resume correctness.
+
+### Fail open, deliberately
+
+A path outside any worktree, a detached HEAD, a foreign repository, a malformed `gitdir:` pointer, and unreadable metadata all leave the call to the rest of the chain. The alternative — denying whenever git identity is unavailable — converts any `.git` permission problem into a harness that cannot write files at all. The guard exists to prevent one specific, recoverable mistake; it must not become a larger outage than the mistake.
+
+### Narrow scope
+
+`read` is never gated: inspecting staging violates nothing, and the skill explicitly permits read-only questions. `bash` is not gated either. Reliably classifying mutating shell commands is a matcher problem with no honest completion condition, so a determined model can still change staging through a shell. This is a boundary against forgetting, not a sandbox against intent.
+
+## Alternatives considered
+
+- **Advisory reminder instead of denial** (`additionalContexts` on `tools/post-execute`, the `repeat-tool-guard` shape). Rejected: the write has already happened when the reminder arrives, so the violation is committed and the guidance is again just text.
+- **`{kind: 'ask'}` routed to approval.** Rejected: it prompts on every legitimate task-worktree edit in the common case, and degrades to denial in a composition without approval support, making behavior depend on unrelated plugins.
+- **Running `git rev-parse` through `ctx.subprocess`.** Rejected after measuring the alternative: two file reads answer the same question with no process spawn per gated write, no `git` on `PATH` requirement, and no subprocess dependency. Reading `.git` and `HEAD` is a stable on-disk format, not an implementation detail.
+- **Explicit `protectedRoots` config with no detection.** Rejected: it makes the common case require configuration to be correct, and a stale absolute path silently disables protection.
+- **A configurable staging-branch name pattern** (`stagingBranchPatterns`, default `dsh-staging/*`). Shipped first, then removed: it protects the wrong set in both directions — every stale sibling worktree that runs no launcher, and nothing at all for a maintainer whose branch is named otherwise. Deriving the branch from the launcher needs no configuration and cannot be misconfigured.
+- **Auto-detecting the checkout with no override.** Rejected: the detection is a default, not a law; a deployment guarding a different checkout, or running from an installed copy, needs the explicit value.
+- **Denying everything under the checkout root, `.worktrees/` included.** Rejected: it blocks the workflow the skill prescribes, so the guard would fire on every legitimate task edit.
+- **Gating `bash` with a mutating-command matcher.** Deferred, not rejected: worth revisiting if bypasses are observed in practice. A matcher that is wrong in either direction is worse than an honestly narrow gate.
+
+## Consequences
+
+The rule now holds without depending on the model having read it, and the denial names the path, the branch, and the skill, so the model's next action is determined rather than guessed. Enforcement sits at the operation boundary that owns the decision, so it cannot be bypassed by prompt filtering or listener order.
+
+Shipping it in the TUI default means every source install is protected without configuration, and the protection follows the launcher across upgrades because the branch is derived rather than named. The cost of that reach is that the plugin loads for every user, including those whose workspace it can never match.
+
+What it cost otherwise: the guard is only as complete as its tool list, and `bash` remains open. Worktree identity is cached per directory for the plugin's lifetime, so a mid-session branch switch is not observed on either side. Only the launcher's own checkout is protected, so a stale sibling stays editable. Loading the skill lifts the gate for the whole session without verifying the workflow was actually followed — the gate proves the instructions were read, not obeyed. Satisfaction is per session, so a subagent with its own session must load the skill itself.
+
+## Testing
+
+Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to one repository, a malformed pointer, and unreadable metadata — covering both source files to per-file 100%. A companion `invariant.ts` validates the durable denial's shape, since the refusal text is the package's only model-visible output and is actionable only when it names the path, branch, and skill.
+
+The real-composition smoke boots `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` through the Loader and the headless app, and asserts three things about the assembled run: the tool result is an error, its text is the exact denial, and the targeted file still holds its original bytes — enforcement before dispatch, not advice after it.
+
+An ACP snapshot scenario (`source-guard-staging-deny`) originally owned the assembled transcript, seeding a staging worktree in the harness's generated cwd through a new `Scenario.prepareCwd` hook — git never tracks an entry named `.git` and `.gitignore` excludes every `worktrees/` directory, so the fixture committed the two `HEAD` bodies and the hook assembled the real layout. Authoring it paid for itself immediately: it exposed both path defects above (the transcript showed `fs-policy` answering first wherever the guard had quietly declined to judge) and then caught its own first fixture, whose ignored `worktrees/` path passed locally from an untracked file. The scenario was later removed with the assembled-run evidence consolidated into the Loader-composition smoke; the `prepareCwd` hook it introduced remains part of the snapshot harness for repository-shaped fixtures.
+
+## Related
+
+- [The personal-staging maintenance skills Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md) — the workflow this gate enforces one rule of. That note owns the skills' content and discovery; this one owns the enforcement point and holds no authority over the workflow itself.
+- [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary this gate's denial uses.
+- [The repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md) — the sibling guard whose advisory shape this one deliberately does not take.
diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md
new file mode 100644
index 0000000000..83589ce833
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md
@@ -0,0 +1,76 @@
+# Agent Note: source-guard 拒绝直接编辑 staging 检出目录
+
+Status: implemented
+
+[English](2026-07-28-source-guard-staging-edit-gate.md) | 中文
+
+## Problem
+
+[`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill(技能)规范对 dsh 源码检出的每项个人变更:先在从 staging 分支顶端分出的任务 worktree 中实现,再于 `.agents/merge.lock` 保护下完成集成。它的核心规则是一条禁令:不得直接编辑个人 staging 检出目录;而若只通过提示词文本传达禁令,它恰好会在最要紧的场景中失效。从未加载该 skill 的 agent(智能体)根本看不到规则;即便尽早加载,也仍可能在三十次工具调用后将其忘掉。这种失败既静默又代价高昂:提交会落在启动器实际运行的 staging 分支上,不属于任何任务分支,既未持有锁,也没有用于回滚的 worktree。
+
+提示词指导无法解决这个问题,因为未被阅读的正是这些指导。该规则需要一个强制执行点。
+
+## Decision
+
+`@deepseek-ai/dsh-source-guard`(`packages/guard/source-guard/`)是一个 `tools/pre-execute` 监听器;它会返回 `{kind: 'deny', reason}`,拒绝目标解析到受保护 staging worktree 内的 `write` 或 `edit`,除非调用会话的持久日志已经记录过一次成功的 `skill` 调用,且名称为 `dsh-customize`。它不注册服务,也不贡献提示词文本或工具 schema;获准的调用与未加载该插件时的调用没有区别。任何已交付的默认组合都不包含它。
+
+### 从文件而非路径前缀或 `git` 判定 Git 身份
+
+系统通过读取 `.git`、其中的 `gitdir:` 指针以及 `HEAD` 来判断路径是否受保护。它可以解析三种形态:普通克隆(`.git` 是目录,且自身就是共享目录)、链接 worktree(`.git` 是文件,指向 `/worktrees/`,其共享目录位于上两级)以及 HEAD 分离状态(`HEAD` 保存原始对象 id,不指向任何分支)。`gitdir:` 指针无论是绝对路径(`git worktree add` 写入的形式)还是相对路径都可以解析;Git 会以包含该指针的 worktree 目录为基准解析相对路径。
+
+只有目标的 worktree 在两项身份上都与启动器的 worktree 匹配时才会拒绝:共用同一个共享 Git 目录,且分支相同。这两项身份均通过解析 `protectedCheckout` 得出,因此无需配置受保护分支的任何信息。较早版本则匹配 `dsh-staging/*` 名称模式;现已改用确切分支规则,因为模式会在两个方向上出错。它会拒绝旧安装留下的每一个同级 staging worktree,尽管其中没有任何一个运行着启动器;对于 staging 分支不遵循任何命名约定的维护者,它又会静默地完全不提供保护——而已交付的默认配置必须在 [`scripts/install.sh`](../../../../scripts/install.sh) 未创建的检出目录上也能生效,这一属性是致命的。
+
+路径前缀规则不仅不精确,而且本身就是错误的。该 skill 规定的任务 worktree 位于受保护树*内部*的 `/.worktrees/...`,因此前缀规则会拒绝工作流要求的每一次编辑。解析过程从目标向外逐层查找,遇到第一个所属 worktree 时停止,因此返回最内层的 worktree:嵌套的任务 worktree 会返回自身的任务分支并获准,而启动器自身所在的树会返回启动器的分支并被拒绝。
+
+有两个路径细节决定门禁究竟能否生效,二者都是强制执行要求,而非细节润色。仓库身份会按解析符号链接后的路径进行比较(使用 `dsh-sandbox` 的 `canonicalPath`),因为位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径在 macOS 上是同一个目录,若按路径字符串比较,每次写入都会故障放行(fail-open)。此外,相对 `file_path` 会完全按照 `dsh-tool-fs` 的方式,相对于调用会话的工作区解析;若只判断绝对路径,相对路径就会成为绕过门禁访问受保护文件的路径。
+
+`protectedCheckout` 指定受保护检出目录内的一条路径,默认值为本模块自身的文件。由此解析出运行中 harness 的启动来源检出目录——当前运行的部署,无论其分支采用什么名称。若 harness 从已安装副本运行,解析出的会是另一个仓库或没有仓库,因此不会保护任何内容;该规则在源码检出之外没有意义。
+
+已交付的 TUI 组合会以这些默认值加载插件,因此每个源码安装无需配置即可受到保护。对于普通项目,它不会生效:若工作区位于其他仓库中,或不存在工作区,就绝不会匹配启动器的身份。
+
+### 从持久日志回放满足状态
+
+如果日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: }`,且按调用 id 能配对到非错误的 `tool/result`,门禁即解除。二者都已持久化(`packages/core/session/src/types.ts`),因此无需新增会话事件,也不与 skill 提供方内部实现耦合。
+
+日志是唯一状态。在内存中记录满足状态(`WeakMap` 结构,[`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) 将其用于调用链)所需实现会更小,但恢复后满足状态会丢失:一个已经读取过该 skill 的恢复会话会被要求再次读取,而这次拒绝看起来会像缺陷而不是规则。回放的代价是扫描日志,但首次命中即停止,并换来恢复行为正确。
+
+### 刻意采用故障放行
+
+目标路径不在任何 worktree 内、HEAD 分离、属于其他仓库、`gitdir:` 指针格式错误或元数据不可读时,调用都会交给调用链的其余部分处理。反过来,只要无法判定 Git 身份就拒绝,会让任何 `.git` 权限问题都导致 harness 完全无法写文件。该 guard 旨在防止一种特定且可恢复的错误,不得造成比该错误更严重的故障。
+
+### 范围收窄
+
+`read` 从不受门禁限制:检查 staging 不会违反任何规则,而且该 skill 明确允许只读提问。`bash` 同样不受门禁限制。要可靠判定哪些 shell 命令会修改状态,需要构造一个无法给出可信完备标准的匹配器,因此执意修改的模型仍可通过 shell 修改 staging。这是一道防止遗忘的边界,不是阻止刻意操作的沙箱。
+
+## Alternatives considered
+
+- **用建议性提醒代替拒绝**(使用 `additionalContexts`,挂载在 `tools/post-execute` 上,采用 `repeat-tool-guard` 的形态)。不予采纳:提醒到达时写入已经发生,违规已成事实,而指导又一次沦为纯文本。
+- **将 `{kind: 'ask'}` 交给审批。** 不予采纳:在常见场景中,它会对任务 worktree 内每次合法编辑都发起询问;在没有审批支持的组合中还会退化为拒绝,使行为取决于无关插件。
+- **运行 `git rev-parse`,并通过 `ctx.subprocess` 执行。** 对替代方案进行实测后不予采纳:读取两个文件即可回答同一问题,每次受门禁限制的写入都无需 spawn 进程,不要求 `git` 存在于 `PATH` 中,也不依赖子进程。读取 `.git` 与 `HEAD` 所依据的是稳定的磁盘格式,而非实现细节。
+- **显式配置 `protectedRoots`,不做检测。** 不予采纳:这会让常见场景的保护效果依赖配置正确性,而陈旧的绝对路径会静默禁用保护。
+- **可配置的 staging 分支名称模式**(`stagingBranchPatterns`,默认 `dsh-staging/*`)。最初随产品交付,随后删除:它从两个方向划错了保护范围——既纳入每个不运行启动器的陈旧同级 worktree,又完全不保护分支另有名称的维护者。由启动器派生分支无需配置,也不可能配置错误。
+- **自动检测检出目录,不提供覆盖项。** 不予采纳:检测只是默认行为,而非不可更改的规定;若部署要保护另一个检出目录,或自身从已安装副本运行,就需要显式值。
+- **拒绝检出根目录下的一切操作,包括 `.worktrees/`。** 不予采纳:这会阻断该 skill 规定的工作流,让 guard 在每次合法任务编辑时触发。
+- **用修改类命令匹配器把守 `bash`。** 推迟而非否决:如果实际观察到绕过行为,值得重新考虑。任一方向判断错误的匹配器,都不如如实限定范围的门禁。
+
+## Consequences
+
+如今,该规则无需依赖模型已经读过它也能生效;拒绝理由会列出路径、分支与 skill,让模型的下一步操作明确,无需猜测。强制执行位于拥有该决策的操作边界,因此提示词过滤或监听器顺序都无法绕过它。
+
+将其纳入 TUI 默认组合意味着每个源码安装无需配置即可受到保护;由于分支是派生而非按名称指定,保护会在升级时跟随启动器。这种覆盖范围的代价是插件会为每位用户加载,包括工作区永远不可能匹配启动器身份的用户。
+
+除此之外的代价是:guard 的完整程度受限于其工具列表,`bash` 仍保持开放。worktree 身份在插件生命周期内按目录缓存,因此无法观察到任一侧在会话中途切换分支。只保护启动器自身的检出目录,因此陈旧的同级检出目录仍可编辑。加载该 skill 会为整个会话解除门禁,却不会验证工作流是否确实得到遵循——门禁只能证明指令已被阅读,不能证明已被执行。满足状态按会话隔离,因此拥有独立会话的 subagent 必须自行加载该 skill。
+
+## Testing
+
+单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖一个 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径、格式错误的指针以及不可读元数据,使两个源码文件都达到逐文件 100% 覆盖率。配套的 `invariant.ts` 会验证持久拒绝的结构,因为拒绝文本是该包唯一面向模型的输出,且只有其中列出路径、分支和 skill 时才具有可操作性。
+
+真实组合冒烟测试通过 Loader 与 headless 应用启动 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`,并对组装后的运行断言三项事实:工具结果是错误、文本与拒绝理由逐字一致、目标文件仍保留原始字节。这证明系统在分发前强制执行规则,而不是事后给出建议。
+
+一个 ACP(Agent Client Protocol)快照场景(`source-guard-staging-deny`)最初负责组装后的 transcript(文本记录),通过新的 `Scenario.prepareCwd` 钩子在 harness 生成的 cwd 中植入 staging worktree——Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此 fixture 提交两个 `HEAD` 的内容,由钩子组装真实布局。编写它立刻证明了投入的价值:它暴露了上述两个路径缺陷(transcript 显示每当 guard 悄然不作判断时 `fs-policy` 都会率先响应),随后又发现了自身首版 fixture 的问题——被忽略的 `worktrees/` 路径因未跟踪文件而在本地通过。该场景后来被移除,组装运行证据合并进 Loader 组合冒烟测试;它引入的 `prepareCwd` 钩子仍留在快照 harness 中,服务于仓库形态的 fixture。
+
+## Related
+
+- [个人 staging 维护 skill 的 Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md):本门禁负责执行该工作流的一条规则。对方 Agent Note 负责这些 skill 的内容与发现机制;本文只负责强制执行点,对工作流本身不具有定义权。
+- [拦截 seam Agent Note](2026-06-30-interception-seams.md):本门禁拒绝时使用的 `tools/pre-execute` `allow`/`deny`/`ask` 词汇。
+- [repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md):同类 guard;本文刻意不采用其建议性形态。
diff --git a/apps/cli/README.md b/apps/cli/README.md
index 93c36d18ab..5241a29b4c 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -2,18 +2,24 @@
English | [中文](README.zh.md)
-The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
+The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, `dsh meta` boots that same TUI over this harness checkout, `dsh migrate` and `dsh upgrade` boot a fresh guided TUI session whose first turn invokes a bundled skill, `dsh list-sessions` lists the sessions running right now, and `dsh web` serves the browser UI.
-Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
+Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, whose `list-sessions` subcommand (alias `ps`) lists live sessions, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `list-sessions`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
The TUI surface:
- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
-- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session;
-- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
+- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session;
+- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below);
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
+`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd, the `./.sessions` persistence root, and the HMR watch root all move together. It accepts only `--resume `; `--config` (which would boot a foreign tree) and `-p` (which is not interactive) fail loud. Because meta sessions live under the checkout, `--resume` here sees only other meta sessions, and both the in-place handoff and the printed exit line reproduce the mode as `dsh meta --resume `, so a copied command resumes the right session from any directory.
+
+`dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection.
+
+`dsh list-sessions` lists the sessions running right now: session id, pid, uptime, workspace, and title, newest first. It is read-only and boots no agent tree — it mounts the [session registry](../../packages/session-registry/session-registry/README.md) alone, so listing is fast and cannot start model work as a side effect. Every surface publishes its sessions into that registry through [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md), and records whose process is gone are pruned on read, so a crashed session disappears without cleanup. `--json` emits the same records as a machine-readable array; an empty listing prints one line and exits 0. There is no workspace filter: the listing is always every live session, whatever directory it runs in. Only top-level surfaces appear — subagents share or spawn other processes and are deliberately invisible.
+
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md
index 85f4624a59..69324735cb 100644
--- a/apps/cli/README.zh.md
+++ b/apps/cli/README.zh.md
@@ -2,18 +2,24 @@
[English](README.md) | 中文
-`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。
+`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh meta` 以本 harness checkout 为 workspace 启动同一个 TUI,`dsh migrate` 和 `dsh upgrade` 启动一个全新的引导式 TUI 会话并在首轮调用内置 skill,`dsh list-sessions` 列出此刻正在运行的会话,`dsh web` 则提供浏览器 UI。
-Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
+Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`migrate`/`upgrade` 子命令是无选项的引导会话入口,`list-sessions` 子命令(别名 `ps`)列出存活会话,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`list-sessions`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
TUI 界面:
- 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动;
-- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume ` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id(不使用环境变量),已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话;
-- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析;
+- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话;
+- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文);
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。
+`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd、`./.sessions` 持久化根目录与 HMR 监视根目录会一并移动。它只接受 `--resume `;`--config`(会启动其他配置树)和 `-p`(非交互)都会明确报错。由于 meta 会话位于该 checkout 之下,此处的 `--resume` 只能看到其他 meta 会话;原地移交与打印的退出行都会以 `dsh meta --resume ` 复现该 mode,因此复制的命令在任何目录下都能恢复到正确的会话。
+
+`dsh migrate` 与 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:各自在调用目录中创建一个全新会话,并以一个内置 skill 播种其首轮(`dsh-migrate` 用于从其他编码 agent 迁移——opencode、pi、Claude Code、Codex;`dsh-upgrade` 用于升级本 checkout),效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。
+
+`dsh list-sessions` 列出此刻正在运行的会话:会话 id、pid、运行时长、工作区和标题,最新的在前。它是只读的,不启动任何 agent 树——它只挂载[会话注册表](../../packages/session-registry/session-registry/README.md),因此列表既快,也不会作为副作用启动模型工作。每个界面都通过 [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md) 把自己的会话发布到该注册表,进程已不存在的记录会在读取时被剪除,因此崩溃的会话无需清理便会消失。`--json` 以机器可读的数组形式输出同样的记录;空列表打印一行并以 0 退出。没有工作区过滤:列表始终是全部存活会话,无论它们运行在哪个目录下。只有顶层界面会出现——subagent 共用别的进程,或 spawn 出别的进程,因此被刻意排除在列表之外。
+
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 0914aaf5aa..783fff6487 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -70,6 +70,9 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
+ "@deepseek-ai/dsh-session-registry": "workspace:^",
+ "@deepseek-ai/dsh-session-registry-file": "workspace:^",
+ "@deepseek-ai/dsh-session-registry-live": "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/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts
index 668d8f7f02..02bf596403 100644
--- a/apps/cli/src/app-cli-entry.ts
+++ b/apps/cli/src/app-cli-entry.ts
@@ -18,7 +18,7 @@ import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot'
-import { resolveDshHome } from '@deepseek-ai/dsh-paths'
+import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -178,11 +178,11 @@ export class AppCLIEntry {
overrides.set(entryId, bag)
}
- // Source 0: computed engineering defaults. The session store defaults to
- // a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history
- // is shared across every cwd, not a project-local ./.sessions. The profile
+ // Source 0: computed engineering defaults. The session store is the one
+ // shared root every dsh surface resolves, so history follows the user across
+ // working directories instead of splitting per project. The profile
// (Source 1) overwrites this same field via last-write-wins in put().
- put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions'))
+ put('session-persistence-jsonl', 'root', resolveSessionsRoot())
// Source 1: profile json (missing file = empty; unmapped key = loud).
for (const [key, value] of Object.entries(this.readProfile())) {
@@ -209,6 +209,7 @@ export class AppCLIEntry {
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
+
this.patches = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts
index b929dc73f2..d143430ed4 100644
--- a/apps/cli/src/args.ts
+++ b/apps/cli/src/args.ts
@@ -2,9 +2,9 @@
* Commander adapter for the `dsh` command-line entry: the one place argv is
* parsed and routed to a mode. `bin.ts` switches on the returned discriminant
* and dynamic-imports that mode's module. One program: the default (no
- * subcommand) is the TUI/headless surface with option-only flags; `web` is a
- * real subcommand. Commander owns `--help`/`--version` and parse errors — it
- * prints and exits at the point of failure (a domain failure routes through
+ * subcommand) is the TUI/headless surface with option-only flags; `meta` and
+ * `web` are real subcommands. Commander owns `--help`/`--version` and parse
+ * errors — it prints and exits at the point of failure (a domain failure routes through
* `command.error`), so this returns only a resolved mode.
* @module @deepseek-ai/dsh/args
*/
@@ -24,6 +24,39 @@ interface HeadlessInvocation {
prompt: string
}
+/**
+ * Interactive TUI over this harness checkout: `dsh meta`. Identical to
+ * {@link TuiInvocation} except the workspace is the launcher's own source tree
+ * rather than the invoking directory. No `--config`: booting a foreign tree
+ * against the harness workspace is the `--config` case, not this one.
+ */
+interface MetaInvocation {
+ mode: 'meta'
+ resume?: string
+}
+
+/**
+ * Guided fresh-session entries: `dsh migrate` seeds the first turn with the
+ * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a
+ * fresh session in the invoking directory and takes no options — `--resume`,
+ * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry.
+ */
+interface SkillSessionInvocation {
+ mode: 'migrate' | 'upgrade'
+}
+
+/**
+ * List live sessions: `dsh list-sessions` (alias `dsh ps`). A read-only surface
+ * that boots no agent tree — it reads the cross-process session registry and
+ * exits. `json` selects the machine-readable form over the human table. There
+ * is no workspace filter: the listing is always every live session, whatever
+ * directory it runs in.
+ */
+interface ListSessionsInvocation {
+ mode: 'list-sessions'
+ json: boolean
+}
+
/**
* Browser UI: `dsh web`. `host`/`port` are present only when the flag was
* passed — pass-through overrides with no CLI default and no CLI validation:
@@ -45,7 +78,13 @@ interface WebInvocation {
}
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
-export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
+export type DshInvocation =
+ | TuiInvocation
+ | HeadlessInvocation
+ | MetaInvocation
+ | SkillSessionInvocation
+ | ListSessionsInvocation
+ | WebInvocation
/** Raw web-subcommand options straight from Commander. */
interface WebOptions {
@@ -86,13 +125,22 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
const program = new Command()
.name('dsh')
.version(version, '-V, --version', 'output the version number')
- .description('dsh: interactive TUI (default), headless task, and browser UI')
+ .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.')
+ // The default surface takes no positional task, so `dsh "task"` fails
+ // commander's arity check with no hint; these examples are where a first
+ // reader learns the entry points and that a one-shot task rides `-p`.
+ .addHelpText('after', `
+Examples:
+ dsh start an interactive session in this directory
+ dsh -p "run the tests" answer one task, print the result, and exit
+ dsh --resume continue a past session (list ids with \`dsh ps\`)
+`)
.exitOverride()
// Default surface: option-only (no positional), so `web` can be a real
// subcommand without a positional collision.
- .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)')
- .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit')
- .option('--resume ', 'resume the persisted session with this id (TUI mode)')
+ .option('-p, --prompt ', 'answer this task without the interactive UI, then exit')
+ .option('--resume ', 'continue a past session by id (list ids with `dsh ps`)')
+ .option('--config ', 'start with an alternate plugin configuration file')
.action((options: { config?: string; prompt?: string; resume?: string }) => {
if (options.prompt !== undefined) {
// A headless prompt owns the invocation; an empty task has nothing to
@@ -115,25 +163,84 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
}
})
- const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
+ // Commander parses the parent (default-surface) options on either side of a
+ // subcommand into `program.opts()`. For a subcommand that shares none of them,
+ // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail
+ // loud rather than silently run and drop the input.
+ const rejectParentOptions = (command: string): void => {
+ const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
+ if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
+ program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`)
+ }
+ }
+
+ // Registration order is the rendered help order, so daily use comes first
+ // and the harness-development surfaces (`web --dev`, `meta`) come last.
+ // `migrate` and `upgrade` are guided fresh-session entries: they take no
+ // options and always mint a fresh session, so nothing is left to carry. Each
+ // description names the outcome, not the skill the first turn invokes.
+ const guided = {
+ migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)',
+ upgrade: 'update this dsh installation to the latest version',
+ } as const
+ for (const mode of ['migrate', 'upgrade'] as const) {
+ program
+ .command(mode)
+ .description(guided[mode])
+ .action(() => {
+ rejectParentOptions(mode)
+ resolved = { mode }
+ })
+ }
+
+ program
+ .command('list-sessions')
+ .alias('ps')
+ .description('list sessions running right now')
+ .option('--json', 'print the records as a JSON array instead of a table')
+ .action((options: { json?: boolean }) => {
+ rejectParentOptions('list-sessions')
+ resolved = { mode: 'list-sessions', json: options.json === true }
+ })
+
+ // Host and port name no default: the CLI passes neither through when the flag
+ // is absent, so the shipped `cordis.yml` value stands and restating it here
+ // would duplicate a fact this file does not own.
+ const web = program.command('web').description('serve the browser UI on the configured host and port')
web
- .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)')
- .option('--port ', 'override the config listen port (0 requests an OS-assigned port)')
- .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
- .option('--workspace-root ', 'parent directory for name-created workspaces')
+ .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine')
+ .option('--port ', 'listen port; pass 0 to let the OS pick a free one')
+ .option('--dev', 'developer mode: hot-reload the browser client')
+ .option('--workspace-root ', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.action((options: WebOptions) => {
- // Commander parses the parent (default-surface) options on either side of
- // the subcommand into `program.opts()`. `web` shares none of them, so a
- // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must
- // fail loud rather than silently start the web server and drop it.
- const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
- if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
- program.error('error: web takes none of --config, -p/--prompt, or --resume')
- }
+ rejectParentOptions('web')
resolved = resolveWeb(options)
})
+ // `--resume` is NOT redeclared here: an option a subcommand shares with its
+ // parent parses into `program.opts()` and leaves the subcommand's own options
+ // empty, so redeclaring it would silently drop the id. Commander therefore
+ // omits it from this subcommand's option list, hence the trailing help text.
+ program
+ .command('meta')
+ .description('work on the dsh source that runs this command, from any directory')
+ .addHelpText('after', '\nAccepts --resume to resume a persisted session from this checkout.\n')
+ .action(() => {
+ // Commander parses the parent (default-surface) options on either side of
+ // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so
+ // a leaked `--config`/`-p` is a mistyped invocation that must fail loud
+ // rather than silently be dropped.
+ const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
+ if (parent.config !== undefined || parent.prompt !== undefined) {
+ program.error('error: meta takes neither --config nor -p/--prompt')
+ }
+ // Same reason as the default surface: an empty id would start a fresh
+ // session downstream instead of failing the mistyped resume.
+ if (parent.resume === '') program.error('error: --resume needs a session id')
+ resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } }
+ })
+
try {
program.parse(argv, { from: 'user' })
} catch (error) {
diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts
index 88dbece55a..8079db1cd4 100644
--- a/apps/cli/src/bin.ts
+++ b/apps/cli/src/bin.ts
@@ -43,6 +43,22 @@ switch (invocation.mode) {
await runTui(invocation.config, invocation.resume)
break
}
+ case 'meta': {
+ const { runMeta } = await import('./tui.ts')
+ await runMeta(invocation.resume)
+ break
+ }
+ case 'list-sessions': {
+ const { runListSessions } = await import('./list-sessions.ts')
+ await runListSessions(invocation.json)
+ break
+ }
+ case 'migrate':
+ case 'upgrade': {
+ const { runSkillSession } = await import('./tui.ts')
+ await runSkillSession(`dsh-${invocation.mode}`)
+ break
+ }
default:
invocation satisfies never
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts
index d7588ccae4..7ee18c1ea1 100644
--- a/apps/cli/src/headless.ts
+++ b/apps/cli/src/headless.ts
@@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AppCLIEntry } from './app-cli-entry.ts'
+import { registerLiveSessions } from './register-session.ts'
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
@@ -80,6 +81,7 @@ export async function runHeadless(task: string): Promise {
port: 0,
})
const { ctx, port } = await entry.run()
+ await registerLiveSessions(ctx)
const dispose = async (): Promise => { await ctx.fiber.dispose() }
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
diff --git a/apps/cli/src/list-sessions.ts b/apps/cli/src/list-sessions.ts
new file mode 100644
index 0000000000..08a972f402
--- /dev/null
+++ b/apps/cli/src/list-sessions.ts
@@ -0,0 +1,101 @@
+/**
+ * `dsh list-sessions` (alias `dsh ps`) — list the sessions running right now.
+ *
+ * A read-only surface: it mounts the session registry alone and never boots an
+ * agent tree, so listing stays fast and cannot start model work as a side
+ * effect. Liveness comes from the registry, which prunes records whose process
+ * is gone, and every displayed field including the title comes from the record,
+ * so no session log is opened and no backend format is assumed.
+ * @module @deepseek-ai/dsh/list-sessions
+ */
+
+import { Context } from 'cordis'
+import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
+import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
+import { registryRoot } from './register-session.ts'
+
+/** Column header text, also the minimum width of each column. */
+const HEADERS = ['SESSION', 'PID', 'UPTIME', 'WORKSPACE', 'TITLE'] as const
+
+/** Shown when a session has no title yet. */
+const NO_TITLE = '—'
+
+/**
+ * Render milliseconds of uptime as a compact human duration.
+ * @param ms - elapsed milliseconds since the session registered.
+ * @returns a short duration such as `12s`, `4m`, or `2h14m`.
+ */
+export function formatUptime(ms: number): string {
+ const seconds = Math.max(0, Math.floor(ms / 1000))
+ if (seconds < 60) return `${String(seconds)}s`
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return `${String(minutes)}m`
+ const hours = Math.floor(minutes / 60)
+ const remainder = minutes % 60
+ if (hours < 24) return remainder === 0 ? `${String(hours)}h` : `${String(hours)}h${String(remainder)}m`
+ const days = Math.floor(hours / 24)
+ const leftoverHours = hours % 24
+ return leftoverHours === 0 ? `${String(days)}d` : `${String(days)}d${String(leftoverHours)}h`
+}
+
+/** One fully-resolved listing row, in column order. */
+type Row = readonly [string, string, string, string, string]
+
+/**
+ * Build the display rows for a listing, newest session first.
+ * @param records - the live records to render.
+ * @param now - the current epoch milliseconds uptime is measured against.
+ * @returns one row per record, each already stringified per column.
+ */
+export function buildRows(records: readonly SessionRegistryRecord[], now: number): Row[] {
+ return [...records]
+ .sort((left, right) => right.startedAt - left.startedAt)
+ .map(record => [
+ record.sessionId,
+ String(record.pid),
+ formatUptime(now - record.startedAt),
+ record.cwd,
+ record.title ?? NO_TITLE,
+ ] as const)
+}
+
+/**
+ * Render rows as a left-aligned table with a header line.
+ *
+ * The last column is never padded, so a long title cannot add trailing
+ * whitespace to every line.
+ * @param rows - the rows to render, already stringified.
+ * @returns the complete table text, newline-terminated.
+ */
+export function renderTable(rows: readonly Row[]): string {
+ const widths = HEADERS.map((header, column) =>
+ Math.max(header.length, ...rows.map(row => row[column]?.length ?? 0)))
+ const line = (cells: readonly string[]): string =>
+ cells.map((cell, column) => column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(' ').trimEnd()
+ return [line(HEADERS), ...rows.map(row => line(row))].join('\n') + '\n'
+}
+
+/**
+ * List live sessions and exit. Prints a table by default, or a JSON array with
+ * `--json`; an empty listing is a success, not an error.
+ * @param json - emit the machine-readable JSON array instead of the table.
+ */
+export async function runListSessions(json: boolean): Promise {
+ const ctx = new Context()
+ await ctx.plugin(SessionRegistryFile, { root: registryRoot() })
+ const records = await ctx.sessionRegistry.list()
+ await ctx.fiber.dispose()
+
+ if (json) {
+ const rows = [...records]
+ .sort((left, right) => right.startedAt - left.startedAt)
+ .map(record => ({ ...record, uptimeMs: Date.now() - record.startedAt, title: record.title ?? null }))
+ process.stdout.write(`${JSON.stringify(rows, undefined, 2)}\n`)
+ return
+ }
+ if (records.length === 0) {
+ process.stdout.write('no dsh sessions running\n')
+ return
+ }
+ process.stdout.write(renderTable(buildRows(records, Date.now())))
+}
diff --git a/apps/cli/src/register-session.ts b/apps/cli/src/register-session.ts
new file mode 100644
index 0000000000..ff78456e8a
--- /dev/null
+++ b/apps/cli/src/register-session.ts
@@ -0,0 +1,44 @@
+/**
+ * Mounts the cross-process live-session registry that `dsh list-sessions` reads, plus the
+ * publisher that keeps it in step with this process's sessions.
+ *
+ * Both plugins mount on the booted app's own context, so records share that
+ * fiber's lifetime: an ordinary exit disposes the fiber and deregisters, while a
+ * killed process leaves records the next reader prunes by pid. Only top-level
+ * surfaces a user launches mount this — in-process subagents have no process of
+ * their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent`
+ * rather than this CLI, so neither reaches this path.
+ * @module @deepseek-ai/dsh/register-session
+ */
+
+import { join } from 'node:path'
+import type { Context } from 'cordis'
+import { resolveDshHome } from '@deepseek-ai/dsh-paths'
+import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
+import * as sessionRegistryLive from '@deepseek-ai/dsh-session-registry-live'
+
+/** Registry root under the Harness home, shared by every surface and by `dsh list-sessions`. */
+export const registryRoot = (): string => join(resolveDshHome(), 'run')
+
+/**
+ * Publish this process's sessions for the lifetime of `ctx`.
+ *
+ * Publication follows session lifecycle rather than a launcher-known id, so one
+ * path serves every surface identically — the TUI's single session and a
+ * server's on-demand ones alike — and titles reach the listing as they are
+ * logged.
+ *
+ * Mounting is best-effort: a registry failure must not take down a working agent
+ * session, because the registry is an observability aid rather than part of the
+ * agent's contract. Failures warn through the context logger.
+ * @param ctx - the booted app context whose lifetime the records share.
+ */
+export async function registerLiveSessions(ctx: Context): Promise {
+ try {
+ const scope = ctx.isolate('sessionRegistry')
+ await scope.plugin(SessionRegistryFile, { root: registryRoot() })
+ await scope.plugin(sessionRegistryLive)
+ } catch (error) {
+ ctx.logger('dsh').warn('session registry unavailable; `dsh list-sessions` will not list these sessions: %s', String(error))
+ }
+}
diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts
index 0d402c1c51..fb1c354d46 100644
--- a/apps/cli/src/tui.ts
+++ b/apps/cli/src/tui.ts
@@ -4,13 +4,20 @@
* from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence:
* ambient environment, then the invoking directory's `.env`, then the personal one)
* and its `config.yaml` patches the booted tree. The workspace is the invoking
- * directory: sessions, relative paths, and workspace instructions resolve from
- * the cwd, so `dsh` acts on whatever project it is launched in. After boot, the
- * agent's system prompt is told the path to this harness checkout so it can find
- * its own source.
+ * directory: the session cwd, relative paths, and workspace instructions resolve
+ * from it, so `dsh` acts on whatever project it is launched in. Session storage
+ * is the exception — it lives under the Harness home so `/resume` reaches every
+ * workspace, and an in-place resume enters the selected session's own directory.
+ * `dsh meta`
+ * ({@link runMeta}) is the one exception — it makes this harness checkout the
+ * workspace. `dsh migrate`/`dsh upgrade` ({@link runSkillSession}) are fresh
+ * sessions whose first turn auto-invokes a bundled skill. After boot, the
+ * agent's system prompt is told the path to this harness checkout so it can
+ * find its own source.
* @module @deepseek-ai/dsh/tui
*/
+import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
@@ -19,13 +26,18 @@ import {
installFailLoud,
loadEnv,
loadPersonalPatches,
- RESUME_SESSION_ID_KEY,
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
-import { resolveDshHome } from '@deepseek-ai/dsh-paths'
+import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths'
+import { SessionId } from '@deepseek-ai/dsh-session'
import type { Context } from 'cordis'
+import { registerLiveSessions } from './register-session.ts'
import {
+ INITIAL_SKILL_KEY,
+ MAIN_SESSION_ID_KEY,
+ SESSIONS_ROOT_KEY,
TUI_GOODBYE_MESSAGE_KEY,
+ type MainSessionIdentity,
type TuiResumeHost,
} from '@deepseek-ai/dsh-tui'
@@ -41,18 +53,65 @@ const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis
// symlink, an arbitrary cwd). The agent is told where its own source lives.
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
+/**
+ * The value `dsh` provides on the {@link SESSIONS_ROOT_KEY} boot slot: its
+ * shared session-store root, `sessions` under the Harness home. Shared-store
+ * policy is the launcher's alone — the app bundle treats the slot as opaque and
+ * keeps a project-local fallback, so only `dsh` decides that sessions are
+ * shared across working directories (making `/resume` and `list-sessions` span
+ * every workspace).
+ * @returns the absolute session-store root this launcher shares.
+ */
+export function launcherSessionsRoot(): string {
+ return resolveSessionsRoot()
+}
+
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
the tui-agent PTY smoke drives this path end to end, personal overlay included */
+/**
+ * Run the interactive TUI with this harness checkout as the workspace
+ * (`dsh meta`), whatever directory it was launched from.
+ * @param resumeSessionId - a persisted session id to resume, or `undefined`;
+ * see {@link runTui}. Meta-mode sessions live under the checkout, so an id from
+ * an ordinary `dsh` run in another directory is not found here.
+ */
+export async function runMeta(resumeSessionId: string | undefined): Promise {
+ return runTui(undefined, resumeSessionId, SOURCE_ROOT)
+}
+
+/**
+ * Run the interactive TUI as a guided fresh session whose first turn invokes a
+ * bundled skill (`dsh migrate` → `dsh-migrate`, `dsh upgrade` → `dsh-upgrade`).
+ * Always mints a fresh session in the invoking directory; the skill is seeded
+ * only on this first launch, so a later `--resume` of the session is an ordinary
+ * TUI session with no re-injection.
+ * @param skill - the bundled skill name to auto-invoke as the first turn.
+ */
+export async function runSkillSession(skill: string): Promise {
+ return runTui(undefined, undefined, undefined, skill)
+}
+
/**
* Run the interactive TUI from the invoking directory.
* @param config - a config path to boot instead of the shipped default, or
* `undefined` for the default; already parsed from `--config`.
- * @param resumeSessionId - a persisted session id to resume, or `undefined`;
- * already parsed and non-empty-validated from `--resume`. It is provided on the
- * boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config
- * reads through `!!js` to rehydrate that session.
+ * @param resumeSessionId - a persisted session id to resume, or `undefined` to
+ * mint a fresh one; already parsed and non-empty-validated from `--resume`.
+ * Either way the resulting identity reaches the booted app through
+ * {@link MAIN_SESSION_ID_KEY}, so no config key selects the session.
+ * @param workspace - a directory to make the workspace instead of the invoking
+ * one, or `undefined` to keep the cwd. Only `dsh meta` passes it.
+ * @param initialSkill - a bundled skill to auto-invoke as a fresh session's
+ * first turn, or `undefined`. Set only by {@link runSkillSession} and ignored
+ * on a resume, so it never re-fires; reaches the app through
+ * {@link INITIAL_SKILL_KEY}.
*/
-export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise {
+export async function runTui(
+ config: string | undefined,
+ resumeSessionId: string | undefined,
+ workspace?: string,
+ initialSkill?: string,
+): Promise {
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree
// is logged per-entry rather than rethrown, so a piped launch would
// otherwise settle into an idle UI-less process instead of exiting nonzero.
@@ -66,28 +125,52 @@ export async function runTui(config: string | undefined, resumeSessionId: string
// The bin already loaded the invoking directory's .env; the personal .env
// only fills what is still unset (process.loadEnvFile never overrides).
loadEnv(NAME, resolveDshHome())
+ // Both .env layers are loaded, so switching the workspace here cannot alter
+ // environment precedence. The cwd IS the workspace seam: the shipped config
+ // resolves the session cwd and the HMR watch root from it, so one chdir moves
+ // both together. Sessions themselves live under the Harness home so `/resume`
+ // spans every workspace, and are unaffected by this chdir.
+ if (workspace !== undefined) process.chdir(workspace)
process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills')
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
- // flag, so the resumed process rehydrates through this same intake. The host
- // is offered only when Node exposes `process.execve` and knows its own entry.
+ // flag, so the resumed process rehydrates through this same intake. The
+ // selected session may belong to another workspace, so the handoff also enters
+ // that directory. The host is offered only when Node exposes `process.execve`
+ // and knows its own entry.
const entry = process.argv[1]
const execve = process.execve?.bind(process)
const app: { current?: Context } = {}
- const resumeCommand = (sessionId: string): string =>
- `${NAME} --resume=${sessionId}${config === undefined ? '' : ` --config ${config}`}`
+ // Resuming reproduces THIS invocation with a different id. Meta mode is a
+ // subcommand that rejects `--config`, while the default surface carries it, so
+ // both the in-place handoff and the printed command derive from one shape.
+ // `meta` is only reproducible for a target inside this checkout: it chdirs to
+ // SOURCE_ROOT itself, which would override any other workspace, so a
+ // cross-workspace resume takes the default surface and the caller supplies the
+ // directory instead.
+ const resumeArgs = (sessionId: string, targetCwd?: string): string[] =>
+ workspace !== undefined && (targetCwd === undefined || targetCwd === workspace)
+ ? ['meta', `--resume=${sessionId}`]
+ : [`--resume=${sessionId}`, ...config !== undefined ? ['--config', config] : []]
+ // Mint the fresh id here rather than in the app bundle: the exit line names
+ // the session to resume, so the launcher must know it before the tree boots.
+ const identity: MainSessionIdentity = resumeSessionId === undefined
+ ? { id: SessionId(`main-session-${randomUUID()}`), resume: false }
+ : { id: SessionId(resumeSessionId), resume: true }
+ const goodbye = `To resume this session: ${NAME} ${resumeArgs(identity.id).join(' ')}`
const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : {
async handoff(sessionId, cwd): Promise {
const current = app.current
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
- // Rebuild argv from the parsed config plus the selected id: TUI mode's
- // only arguments are `--config ` and `--resume `.
const nextArgv = [
process.execPath,
...process.execArgv,
entry,
- `--resume=${sessionId}`,
- ...config !== undefined ? ['--config', config] : [],
+ ...resumeArgs(sessionId, cwd),
]
+ // `execve` inherits the cwd, and the target session may belong to another
+ // workspace. Enter it BEFORE teardown commits: an unreachable directory
+ // (deleted, unreadable) must reject while the caller can still restore the
+ // terminal, and a chdir after disposal would have no owner to report to.
try {
process.chdir(cwd)
} catch (error) {
@@ -108,16 +191,27 @@ export async function runTui(config: string | undefined, resumeSessionId: string
resolveConfigPath(config ?? DEFAULT_CONFIG, undefined),
loadPersonalPatches(NAME),
(hostCtx) => {
- // Inject the resume id (or undefined) so the shipped config's `!!js`
- // reads it as a bare identifier; then offer the in-place handoff host.
- hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId)
- if (resumeSessionId !== undefined) {
- hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, `To resume this session: ${resumeCommand(resumeSessionId)}`)
- }
+ // The launcher owns session identity and the exit line: a config-mounted
+ // app bundle reads both from these slots, so no cordis.yml key can drop
+ // resume.
+ hostCtx.provide(MAIN_SESSION_ID_KEY, identity)
+ hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, goodbye)
+ // Shared-store policy is the launcher's: sessions live in one root under
+ // the Harness home across every cwd, so /resume and list-sessions see
+ // every workspace. The bundle treats the slot as opaque.
+ hostCtx.provide(SESSIONS_ROOT_KEY, launcherSessionsRoot())
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
+ // Seed the first turn only for a fresh session, so resuming never
+ // re-invokes the skill.
+ if (initialSkill !== undefined && resumeSessionId === undefined) {
+ hostCtx.provide(INITIAL_SKILL_KEY, initialSkill)
+ }
},
)
app.current = ctx
addHarnessSourceSection(ctx, SOURCE_ROOT)
+ // Publication follows the store; meta mode already chdir'd, so each session
+ // reports its own cwd.
+ await registerLiveSessions(ctx)
}
/* v8 ignore stop */
diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts
index 69e79ab5d9..f4ad016005 100644
--- a/apps/cli/src/web.ts
+++ b/apps/cli/src/web.ts
@@ -8,6 +8,7 @@
import { fileURLToPath } from 'node:url'
import { AppCLIEntry } from './app-cli-entry.ts'
+import { registerLiveSessions } from './register-session.ts'
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
@@ -40,6 +41,7 @@ export async function runWeb(
...trustedHosts !== undefined && { trustedHosts },
})
const { ctx, port: boundPort } = await entry.run()
+ await registerLiveSessions(ctx)
let exiting = false
const shutdown = (code: number): void => {
diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts
index 45830eee30..99569c7cf0 100644
--- a/apps/cli/tests/args.spec.ts
+++ b/apps/cli/tests/args.spec.ts
@@ -24,17 +24,33 @@ function exitCode(argv: string[]): number {
afterEach(() => { vi.restoreAllMocks() })
describe('parseDshArgs', () => {
- it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => {
+ it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => {
expect(parse([])).toEqual({ mode: 'tui' })
expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
+ // `meta` accepts `--resume` but does not redeclare it: a shared option parses
+ // into program.opts() on either side of the subcommand, and redeclaring it
+ // would leave the subcommand's own options empty and drop the id.
+ expect(parse(['meta'])).toEqual({ mode: 'meta' })
+ expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' })
+ expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' })
+ // Credential setup is option-free: it writes the Harness-home .env, so
+ // there is nothing for a flag to select.
// Bare `web` carries no host/port: the shipped cordis.yml owns the default.
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
// Host/port are unvalidated pass-throughs (the webserver schema gates them
// at boot); the adapter only coerces the port string to a number.
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
+ // Guided fresh-session entries carry nothing: bare mode discriminant only.
+ expect(parse(['migrate'])).toEqual({ mode: 'migrate' })
+ expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' })
+ // `list-sessions` has one option and no workspace filter: the listing is always
+ // global. `ps` is its alias and resolves to the same mode.
+ expect(parse(['list-sessions'])).toEqual({ mode: 'list-sessions', json: false })
+ expect(parse(['list-sessions', '--json'])).toEqual({ mode: 'list-sessions', json: true })
+ expect(parse(['ps', '--json'])).toEqual({ mode: 'list-sessions', json: true })
// --trusted-host is variadic and repeatable; authorities pass through unvalidated.
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
@@ -55,6 +71,27 @@ describe('parseDshArgs', () => {
expect(exitCode(['web', '-p', 'task'])).toBe(1)
expect(exitCode(['web', '--resume', 's'])).toBe(1)
expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1)
+ // Same rule for credential setup: it shares no option with the default
+ // surface, so a leaked flag is a typo, not something to ignore.
+ // `meta` fixes its own config tree and is interactive, so --config/-p are
+ // rejected; an empty id is swallowed downstream exactly as above.
+ expect(exitCode(['meta', '--resume='])).toBe(1)
+ expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1)
+ expect(exitCode(['meta', '-p', 'task'])).toBe(1)
+ // `migrate`/`upgrade` take no options: any leaked default-surface flag is a
+ // mistyped invocation, not a silently-dropped input.
+ expect(exitCode(['migrate', '--resume', 's'])).toBe(1)
+ expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1)
+ expect(exitCode(['migrate', '-p', 'task'])).toBe(1)
+ expect(exitCode(['upgrade', '--resume', 's'])).toBe(1)
+ expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1)
+ expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1)
+ // `list-sessions`/`ps` is read-only and shares no default-surface option: a leaked flag is a
+ // mistyped invocation, not a listing with a silently dropped input.
+ expect(exitCode(['ps', '--resume', 's'])).toBe(1)
+ expect(exitCode(['list-sessions', '--config', 'c.yml'])).toBe(1)
+ expect(exitCode(['list-sessions', '-p', 'task'])).toBe(1)
+ expect(exitCode(['--resume', 's', 'ps'])).toBe(1)
})
it('exits 0 for --help (disclosing web) and --version', () => {
diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts
index 1ea7d9f0db..71c8257b08 100644
--- a/apps/cli/tests/built-bin.e2e.ts
+++ b/apps/cli/tests/built-bin.e2e.ts
@@ -1,8 +1,9 @@
-import { existsSync } from 'node:fs'
+import { existsSync, mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
/**
* Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
@@ -16,19 +17,28 @@ import { describe, expect, it } from 'vitest'
* node_modules, so no external consumer is assembled; missing-config fail-loud
* and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's
* built-bin suite, and interactive TTY behavior is PTY-covered by
- * examples/tui-agent. Skips before the bin is built.
+ * examples/tui-agent. `dsh list-sessions` is covered here too: it is the one surface that
+ * boots no agent tree, so the built bin is the whole product path.
+ * Skips before the bin is built.
*/
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
-/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */
-async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
- const result = await execa(process.execPath, [dshBin], {
+/**
+ * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output
+ * + exit code. `env` isolates the Harness home for surfaces that read it.
+ */
+async function runBuiltBin(
+ args: readonly string[] = [],
+ env: Record = {},
+): Promise<{ stdout: string; code: number; stderr: string }> {
+ const result = await execa(process.execPath, [dshBin, ...args], {
input: '',
timeout: 25_000,
killSignal: 'SIGKILL',
reject: false,
+ env,
})
if (result.timedOut) {
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
@@ -45,4 +55,37 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
// The refusal happens before any plugin mounts: stdout stays silent.
expect(stdout).toBe('')
}, 30_000)
+
+ describe('dsh list-sessions', () => {
+ let home: string
+ beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-ls-bin-')) })
+ afterEach(() => { rmSync(home, { recursive: true, force: true }) })
+
+ it('reports an empty listing as success, not an error', async () => {
+ const { stdout, code, stderr } = await runBuiltBin(['list-sessions'], { DSH_HOME: home })
+ expect(code).toBe(0)
+ expect(stdout.trim()).toBe('no dsh sessions running')
+ expect(stderr).toBe('')
+ }, 30_000)
+
+ it('emits an empty JSON array for machines', async () => {
+ const { stdout, code } = await runBuiltBin(['ps', '--json'], { DSH_HOME: home })
+ expect(code).toBe(0)
+ expect(JSON.parse(stdout)).toEqual([])
+ }, 30_000)
+
+ it('runs without a TTY, unlike the TUI surface', async () => {
+ // The listing is read-only and boots no agent tree, so piped stdio — the
+ // launch the TUI refuses — is a supported way to run it.
+ const { code, stderr } = await runBuiltBin(['ps'], { DSH_HOME: home })
+ expect(code).toBe(0)
+ expect(stderr).not.toContain('interactive TTYs')
+ }, 30_000)
+
+ it('rejects a leaked default-surface flag instead of listing', async () => {
+ const { code, stderr } = await runBuiltBin(['list-sessions', '--resume', 'sess'], { DSH_HOME: home })
+ expect(code).not.toBe(0)
+ expect(stderr).toContain('list-sessions takes none of')
+ }, 30_000)
+ })
})
diff --git a/apps/cli/tests/list-sessions.spec.ts b/apps/cli/tests/list-sessions.spec.ts
new file mode 100644
index 0000000000..a73faf1b20
--- /dev/null
+++ b/apps/cli/tests/list-sessions.spec.ts
@@ -0,0 +1,74 @@
+/**
+ * Tests for the `dsh list-sessions` presentation layer: uptime formatting, row building
+ * (newest first, absent-title placeholder) and table alignment without trailing
+ * padding. Every displayed field comes from the record, so there is no log
+ * reading to cover here.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
+import { buildRows, formatUptime, renderTable } from '../src/list-sessions.ts'
+
+function record(overrides: Partial = {}): SessionRegistryRecord {
+ return {
+ sessionId: SessionId('sess-1'),
+ pid: 4242,
+ cwd: '/work/project',
+ startedAt: 1_000,
+ bootId: BootId('boot-1'),
+ ...overrides,
+ }
+}
+
+describe('formatUptime', () => {
+ it.each([
+ [0, '0s'],
+ [999, '0s'],
+ [12_000, '12s'],
+ [59_999, '59s'],
+ [60_000, '1m'],
+ [3_540_000, '59m'],
+ [3_600_000, '1h'],
+ [8_040_000, '2h14m'],
+ [86_400_000, '1d'],
+ [90_000_000, '1d1h'],
+ ])('renders %ims as %s', (ms, expected) => {
+ expect(formatUptime(ms)).toBe(expected)
+ })
+
+ it('never renders a negative duration for a clock that moved backwards', () => {
+ expect(formatUptime(-5_000)).toBe('0s')
+ })
+})
+
+describe('buildRows', () => {
+ it('orders newest first and marks a missing title', () => {
+ const rows = buildRows([
+ record({ sessionId: SessionId('older'), startedAt: 1_000 }),
+ record({ sessionId: SessionId('newer'), startedAt: 5_000 }),
+ ], 65_000)
+ expect(rows.map(row => row[0])).toEqual(['newer', 'older'])
+ expect(rows[0]).toEqual(['newer', '4242', '1m', '/work/project', '—'])
+ })
+})
+
+describe('renderTable', () => {
+ it('aligns columns and leaves no trailing whitespace', () => {
+ const table = renderTable(buildRows([
+ record({ sessionId: SessionId('short'), startedAt: 0, title: 'a title' }),
+ record({ sessionId: SessionId('a-much-longer-session-id'), startedAt: 1, title: 'a title' }),
+ ], 1_000))
+ const lines = table.split('\n')
+ expect(lines[0]).toMatch(/^SESSION {18}\s+PID/)
+ for (const line of lines) expect(line).toBe(line.trimEnd())
+ // The header and every row align on the same column starts.
+ const pidColumn = (line: string): number => line.includes('4242') ? line.indexOf('4242') : line.indexOf('PID')
+ expect(pidColumn(lines[1] ?? '')).toBe(pidColumn(lines[0] ?? ''))
+ expect(pidColumn(lines[2] ?? '')).toBe(pidColumn(lines[0] ?? ''))
+ })
+
+ it('renders a header even with no rows, so the columns stay discoverable', () => {
+ expect(renderTable([])).toBe('SESSION PID UPTIME WORKSPACE TITLE\n')
+ })
+})
diff --git a/apps/cli/tests/sessions-root.spec.ts b/apps/cli/tests/sessions-root.spec.ts
new file mode 100644
index 0000000000..8f12455f1b
--- /dev/null
+++ b/apps/cli/tests/sessions-root.spec.ts
@@ -0,0 +1,20 @@
+/**
+ * Pins the launcher side of the shared-session-store contract: `dsh` defaults
+ * its opaque `SESSIONS_ROOT_KEY` boot-slot value to `DSH_HOME/sessions`. The
+ * plugin side — the slot treated as opaque, explicit config winning, and a
+ * project-local fallback with no globality assumption — is pinned by
+ * `packages/examples/tui-demo/tests/tui-agent.spec.ts`.
+ */
+
+import { join, resolve } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { launcherSessionsRoot } from '../src/tui.ts'
+
+afterEach(() => vi.unstubAllEnvs())
+
+describe('launcherSessionsRoot', () => {
+ it('defaults the boot slot to sessions under DSH_HOME', () => {
+ vi.stubEnv('DSH_HOME', '/tmp/dsh-slot-home')
+ expect(launcherSessionsRoot()).toBe(resolve(join('/tmp/dsh-slot-home', 'sessions')))
+ })
+})
diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json
index ce43c0f31c..ef0a6705ad 100644
--- a/apps/cli/tsconfig.json
+++ b/apps/cli/tsconfig.json
@@ -29,6 +29,15 @@
{
"path": "../../packages/util/paths"
},
+ {
+ "path": "../../packages/session-registry/session-registry"
+ },
+ {
+ "path": "../../packages/session-registry/session-registry-file"
+ },
+ {
+ "path": "../../packages/session-registry/session-registry-live"
+ },
{
"path": "../../packages/client/connection"
},
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 43f5b20716..6de0f7ff6e 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -47,6 +47,10 @@ flowchart LR
pkg_workspace["workspace"]
svc_workspace["ctx.workspace Workspace entity registry"]
pkg_apiproxy["apiproxy"]
+ pkg_session_registry["session-registry"]
+ svc_sessionRegistry["ctx.sessionRegistry Live-session registry"]
+ pkg_session_registry_file["session-registry-file"]
+ pkg_session_registry_live["session-registry-live"]
svc_sessionQuery["ctx.sessionQuery Session reads, traces, filters, and search"]
pkg_session_reference["session-reference"]
pkg_tool_session_query["tool-session-query"]
@@ -197,6 +201,8 @@ flowchart LR
pkg_session_query --> svc_sessionQuery
pkg_session_query_sqlite --> svc_sessionQuery
pkg_session_reference --> svc_sessionReferences
+ pkg_session_registry --> svc_sessionRegistry
+ pkg_session_registry_file --> svc_sessionRegistry
pkg_session_telemetry --> svc_telemetry
pkg_session_telemetry_otel --> svc_telemetry
pkg_session_title --> svc_sessionTitle
@@ -279,6 +285,7 @@ flowchart LR
svc_sessionQuery --> pkg_session_reference
svc_sessionQuery --> pkg_tool_session_query
svc_sessionReferences --> pkg_tui
+ svc_sessionRegistry --> pkg_session_registry_live
svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop
svc_sessions --> pkg_cli_demo
@@ -339,6 +346,7 @@ flowchart LR
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
| `ctx.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.sessionRegistry` | `seam` | [`session-registry`](../packages/session-registry/session-registry) | [`session-registry-file`](../packages/session-registry/session-registry-file) | [`session-registry-live`](../packages/session-registry/session-registry-live) | - | Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`. |
| `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.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. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 3c0543e4ca..8bd6e966b9 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1153,6 +1153,26 @@ export interface Config {
Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts)
+## `@deepseek-ai/dsh-session-registry-file`
+
+```ts config-catalog
+/**
+ * Plugin config as callers write it: `root` is required — a cwd fallback would
+ * scatter registries — while the lock tunables are optional because
+ * `static Config` supplies their defaults.
+ */
+export interface Config {
+ /** Directory holding the registry file; created `0o700` on demand. */
+ root: string
+ /** Milliseconds after which a held lock is considered abandoned and reclaimed. */
+ lockStaleMs?: number
+ /** Retries before a contended acquisition fails loud. */
+ lockRetries?: number
+}
+```
+
+Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts)
+
## `@deepseek-ai/dsh-session-telemetry-otel`
Requires: `sessions`
@@ -1263,6 +1283,38 @@ export interface Config {
Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts)
+## `@deepseek-ai/dsh-source-guard`
+
+Requires: `fs`
+
+```ts config-catalog
+/**
+ * Plugin config, validated by the same-named schemastery schema plus the
+ * load-time checks in `apply` (misconfiguration fails loud: an empty `tools`
+ * list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at
+ * plugin load, never a silent fall-back).
+ */
+export interface Config {
+ /** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */
+ requiredSkill?: string
+ /** Tool names to gate (default `['write', 'edit']`). */
+ tools?: string[]
+ /**
+ * Absolute path inside the checkout this guard protects. Its worktree
+ * supplies BOTH protected identities: the repository (targets in any other
+ * repository are ignored) and the exact branch (only that branch's worktree
+ * is protected). Defaults to this module's own location, which resolves the
+ * checkout the running harness was launched from — the live deployment,
+ * whatever its branch is named. Set it explicitly to guard a different
+ * checkout, or when the harness runs from an installed copy whose own
+ * location is not a checkout at all.
+ */
+ protectedCheckout?: string
+}
+```
+
+Source: [`packages/guard/source-guard/src/index.ts:30`](../packages/guard/source-guard/src/index.ts)
+
## `@deepseek-ai/dsh-spill-local`
```ts config-catalog
@@ -1541,6 +1593,20 @@ export interface Config {
Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts)
+## `@deepseek-ai/dsh-tmux-context`
+
+Requires: `agents`
+
+```ts config-catalog
+/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */
+export interface Config {
+ /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */
+ refreshIntervalMs?: number
+}
+```
+
+Source: [`packages/context/tmux-context/src/index.ts:33`](../packages/context/tmux-context/src/index.ts)
+
## `@deepseek-ai/dsh-token-meter`
```ts config-catalog
@@ -1960,7 +2026,12 @@ export interface Config {
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable
- /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
+ /**
+ * Directory for JSONL sessions and the derived query index. Precedence:
+ * this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot
+ * slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local
+ * `./.sessions` fallback — the bundle itself never assumes a global store.
+ */
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
@@ -1968,13 +2039,6 @@ export interface Config {
sessionReferences?: SessionReferenceConfig
/** TUI transcript's optional first line; absent renders nothing on start. */
welcome?: string
- /**
- * Shell command template the TUI prints on exit and lists under `/resume`,
- * with `{session}` replaced by the live session id (forwarded to the front
- * door). Set it to a command that resumes the session, e.g.
- * `dsh --resume {session}`.
- */
- resumeCommand?: string
/** Full-screen TUI presentation settings. */
ui?: uiTui.TuiConfig
/** Skill registry, local-provider, and model-facing consumer config. */
@@ -1985,8 +2049,6 @@ export interface Config {
toolTasks?: NonNullable
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
- /** Persisted session id to resume instead of creating a fresh session. */
- resumeSessionId?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
}
@@ -1994,7 +2056,7 @@ export interface Config {
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts)
-Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts)
+Source: [`packages/examples/tui-demo/src/index.ts:44`](../packages/examples/tui-demo/src/index.ts)
## `@deepseek-ai/dsh-user-approval`
@@ -2231,6 +2293,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts))
- `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts))
+- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts))
- `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
- `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts))
@@ -2253,6 +2316,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
- `@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))
- `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts))
+- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts))
- `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts))
- `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts))
- `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index db31daaf06..7010113be7 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1464,6 +1464,44 @@ Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-s
Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts)
+## `ctx.sessionRegistry` — `SessionRegistry` (abstract seam)
+
+Cross-process live-session registry. Reads prune dead records, so every returned record's process existed at observation time. Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write.
+
+```ts cordis-catalog
+/**
+ * Publish this process's record, replacing any stale record for the same
+ * session id, and prune records whose process is gone.
+ * @param registration - the session, surface, and workspace to publish.
+ * @returns the effect disposer that removes this record again; awaiting it
+ * waits for the removal to reach durability.
+ */
+abstract register(registration: SessionRegistration): Promise<() => Promise>
+
+/**
+ * Replace the recorded title of a session this process registered.
+ *
+ * Titles arrive after registration and can be revised, so this is the one
+ * mutable field. Only a record matching this process and incarnation is
+ * touched, leaving a same-id record owned by another process alone. An unknown
+ * session id is a no-op rather than an error: a title can resolve after the
+ * session's record has already been removed.
+ * @param sessionId - the session whose recorded title changes.
+ * @param title - the new title text.
+ */
+abstract retitle(sessionId: SessionId, title: string): Promise
+
+/**
+ * List live sessions, pruning records whose process no longer exists.
+ * @returns one record per live registered session, newest registration last.
+ */
+abstract list(): Promise
+```
+
+Types: [SessionId](../core-data-structures/core.md)
+
+Source: [`packages/session-registry/session-registry/src/index.ts:44`](../../packages/session-registry/session-registry/src/index.ts)
+
## `ctx.sessions` — `SessionStore`
In-memory session store (`ctx.sessions`).
diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md
index 5072c2684d..74392c18ef 100644
--- a/docs/i18n/terminology.md
+++ b/docs/i18n/terminology.md
@@ -116,6 +116,7 @@
| extension | 扩展 | | | |
| extension point | 扩展点 | | | 注意与 `seam` 区分 |
| fail-fast | 快速失败 | | | |
+| fail-open | 故障放行 | 故障放行(fail-open) | 故障开放 | 与 `fail-fast` 对称;指无法判定时放行而非阻断 |
| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |
| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |
| finish reason | 结束原因 | | | |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index d3e60978e7..777457020d 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -173,6 +173,7 @@ flowchart TD
subgraph group_context["packages/context"]
pkg_session_reference["session-reference"]
pkg_time_context["time-context"]
+ pkg_tmux_context["tmux-context"]
pkg_workspace_context["workspace-context"]
end
subgraph group_examples["packages/examples"]
@@ -184,6 +185,7 @@ flowchart TD
end
subgraph group_guard["packages/guard"]
pkg_repeat_tool_guard["repeat-tool-guard"]
+ pkg_source_guard["source-guard"]
end
subgraph group_host["packages/host"]
pkg_host_apiproxy["host-apiproxy"]
@@ -221,6 +223,11 @@ flowchart TD
pkg_session_projection["session-projection"]
pkg_session_projection_cache["session-projection-cache"]
end
+ subgraph group_session_registry["packages/session-registry"]
+ pkg_session_registry["session-registry"]
+ pkg_session_registry_file["session-registry-file"]
+ pkg_session_registry_live["session-registry-live"]
+ end
subgraph group_storage["packages/storage"]
pkg_storage["storage"]
pkg_storage_domain["storage-domain"]
@@ -450,6 +457,9 @@ flowchart TD
pkg_sandbox_policy --> pkg_session
pkg_session_projection --> pkg_invariants
pkg_session_projection --> pkg_session
+ pkg_session_registry --> pkg_brand
+ pkg_session_registry --> pkg_invariants
+ pkg_session_registry --> pkg_session
pkg_llm_retry --> pkg_agent
pkg_llm_retry --> pkg_invariants
pkg_llm_retry --> pkg_llm
@@ -523,6 +533,10 @@ flowchart TD
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
pkg_time_context --> pkg_session
+ pkg_tmux_context --> pkg_agent
+ pkg_tmux_context --> pkg_bash
+ pkg_tmux_context --> pkg_invariants
+ pkg_tmux_context --> pkg_session
pkg_pty --> pkg_agent
pkg_pty --> pkg_brand
pkg_pty --> pkg_invariants
@@ -533,6 +547,9 @@ flowchart TD
pkg_session_projection_cache --> pkg_session_persistence
pkg_session_projection_cache --> pkg_session_projection
pkg_session_projection_cache --> pkg_storage_domain
+ pkg_session_registry_file --> pkg_invariants
+ pkg_session_registry_file --> pkg_session
+ pkg_session_registry_file --> pkg_session_registry
pkg_tasks --> pkg_agent
pkg_tasks --> pkg_brand
pkg_tasks --> pkg_invariants
@@ -614,6 +631,10 @@ flowchart TD
pkg_pty_local --> pkg_sandbox_policy
pkg_pty_local --> pkg_session
pkg_pty_local --> pkg_subprocess
+ pkg_session_registry_live --> pkg_invariants
+ pkg_session_registry_live --> pkg_session
+ pkg_session_registry_live --> pkg_session_registry
+ pkg_session_registry_live --> pkg_session_title
pkg_tasks_local --> pkg_agent
pkg_tasks_local --> pkg_invariants
pkg_tasks_local --> pkg_tasks
@@ -776,6 +797,13 @@ flowchart TD
pkg_repeat_tool_guard --> pkg_agent
pkg_repeat_tool_guard --> pkg_invariants
pkg_repeat_tool_guard --> pkg_tools
+ pkg_source_guard --> pkg_agent
+ pkg_source_guard --> pkg_fs
+ pkg_source_guard --> pkg_invariants
+ pkg_source_guard --> pkg_llm
+ pkg_source_guard --> pkg_sandbox
+ pkg_source_guard --> pkg_session
+ pkg_source_guard --> pkg_tools
pkg_tool_lsp --> pkg_invariants
pkg_tool_lsp --> pkg_llm
pkg_tool_lsp --> pkg_lsp
@@ -1045,6 +1073,7 @@ flowchart TD
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
+| [`session-registry`](../packages/session-registry/session-registry) | `session-registry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
@@ -1062,9 +1091,11 @@ flowchart TD
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
+| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`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) |
| [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
+| [`session-registry-file`](../packages/session-registry/session-registry-file) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry) |
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -1080,6 +1111,7 @@ flowchart TD
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
| [`client-ui-goal`](../packages/client/ui-goal) | `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-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
+| [`session-registry-live`](../packages/session-registry/session-registry-live) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry), [`session-title`](../packages/session-title/session-title) |
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
@@ -1107,6 +1139,7 @@ flowchart TD
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
+| [`source-guard`](../packages/guard/source-guard) | `guard` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`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) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
| [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml
index 83144b2153..601ac94419 100644
--- a/examples/cordis-agent/cordis.yml
+++ b/examples/cordis-agent/cordis.yml
@@ -60,7 +60,6 @@
config:
provider: deepseek
model: deepseek-v4-pro
- resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"
persistenceRoot: './.sessions'
workspaceContext:
maxBytes: 65536
diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml b/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml
new file mode 100644
index 0000000000..a8a83302bb
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml
@@ -0,0 +1,38 @@
+# Test-only composition: the model attempts one `write` into a staging-shaped
+# git fixture, so the guard's denial is observed through the real Loader and app.
+- id: source-guard-mock-llm
+ name: './mock-llm.ts'
+
+# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
+- id: subprocess
+ name: '@deepseek-ai/dsh-subprocess-local'
+
+- id: bash
+ name: '@deepseek-ai/dsh-bash-local'
+
+- id: fs
+ name: '@deepseek-ai/dsh-fs-local'
+
+# Read-before-edit policy: without it the write would resolve `createIfAbsent`
+# and the transcript would not show the guard as the sole reason for refusal.
+- id: fs-policy
+ name: '@deepseek-ai/dsh-fs-policy'
+
+- id: tool-fs
+ name: '@deepseek-ai/dsh-tool-fs'
+
+# Mounts the guard with `protectedCheckout` resolved against the process cwd, so
+# it arms for the staging fixture the smoke builds there rather than for the
+# checkout running the test (the config default is this module's own location).
+- id: source-guard-fixture
+ name: './mount-guard.ts'
+
+- id: cli-agent
+ name: '@deepseek-ai/dsh-cli-demo'
+ config:
+ provider: source-guard-mock
+ model: source-guard-mock
+ persona: 'Test the source guard.'
+ persistenceRoot: './.sessions'
+ persistenceCompression: none
+ workspaceContext: false
diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts
new file mode 100644
index 0000000000..44baccd617
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts
@@ -0,0 +1,43 @@
+import { resolve } from 'node:path'
+import type { Context } from 'cordis'
+import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
+
+/** The staged file the smoke builds in the process cwd; the guard must refuse to write it. */
+const TARGET = resolve('staging/guarded.ts')
+
+/**
+ * Two-step adapter for the source-guard Loader fixture: the first step calls
+ * `write` on the staged file, the second closes the turn once a tool result has
+ * come back, so the transcript records what the model received.
+ */
+class SourceGuardMockAdapter extends LlmAdapter {
+ async * stream(options: GenerateOptions): AsyncIterable {
+ const alreadyCalled = options.messages.some(message => message.content.some(
+ block => block.type === 'tool-result',
+ ))
+ if (alreadyCalled) {
+ const text = 'denied as expected'
+ yield { type: 'block-start', index: 0, blockType: 'text' }
+ yield { type: 'text-delta', index: 0, text }
+ yield { type: 'block-end', index: 0, block: { type: 'text', text } }
+ yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
+ yield { type: 'finish', reason: { kind: 'stop' } }
+ return
+ }
+ const callId = CallId('source-guard-write')
+ const args = JSON.stringify({ file_path: TARGET, content: 'edited\n' })
+ yield { type: 'block-start', index: 0, blockType: 'tool-call' }
+ yield { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: args }
+ yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: args } }
+ yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
+ yield { type: 'finish', reason: { kind: 'tool-calls' } }
+ }
+}
+
+export const name = 'source-guard-mock-llm'
+export const inject = ['llm']
+
+/** Register the test-only `source-guard-mock` adapter. */
+export function apply(ctx: Context): void {
+ ctx.llm.registerAdapter(['source-guard-mock'], new SourceGuardMockAdapter())
+}
diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts
new file mode 100644
index 0000000000..90a89a2897
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts
@@ -0,0 +1,14 @@
+import { resolve } from 'node:path'
+import type { Context } from 'cordis'
+import * as SourceGuard from '@deepseek-ai/dsh-source-guard'
+
+export const name = 'source-guard-fixture'
+
+/**
+ * Mount the real guard against the staging fixture in the process cwd. The
+ * checkout under protection is a runtime fact of the isolated smoke directory,
+ * which no static config value can name.
+ */
+export async function apply(ctx: Context): Promise {
+ await ctx.plugin(SourceGuard, { protectedCheckout: resolve('staging/guard-anchor.ts') })
+}
diff --git a/examples/headless-agent/tests/fixtures/tmux-context-driver.ts b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts
new file mode 100644
index 0000000000..2fd6d0f5ec
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts
@@ -0,0 +1,16 @@
+#!/usr/bin/env node
+/** Test driver that sends two turns through one Headless Loader composition. */
+
+import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
+import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts'
+
+const configPath = process.argv[2]
+if (configPath === undefined) throw new Error('tmux-context driver requires a config path')
+
+const ctx = await boot('tmux-context-e2e', resolveConfigPath(configPath, undefined))
+try {
+ await runOneShot(ctx, { task: 'first' })
+ await runOneShot(ctx, { task: 'second' })
+} finally {
+ await ctx.fiber.dispose()
+}
diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts
new file mode 100644
index 0000000000..3e9540abff
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts
@@ -0,0 +1,46 @@
+import type { Context } from 'cordis'
+import { BashExecutor } from '@deepseek-ai/dsh-bash'
+import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
+
+/**
+ * Deterministic `ctx.bash` for the tmux-context Loader fixture: any command
+ * (the plugin's `tmux display-message`) returns a fixed tab-delimited reading,
+ * so the injected tmux location is stable without a real tmux server. `start()`
+ * throws — tmux-context must never spawn a background process.
+ */
+class TmuxMockBash extends BashExecutor {
+ override resolve(request: BashExecRequest): BashExecSpec {
+ return {
+ command: request.command,
+ workdir: request.workdir ?? process.cwd(),
+ timeoutMs: request.timeoutMs ?? 60_000,
+ stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
+ signal: request.signal,
+ sandboxPolicy: request.sandboxPolicy,
+ }
+ }
+
+ override run(_spec: BashExecSpec): Promise {
+ const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t')
+ return Promise.resolve({
+ exitCode: 0,
+ signal: null,
+ timedOut: false,
+ aborted: false,
+ timeoutMs: 60_000,
+ stdout: { text: `${line}\n`, truncated: false },
+ stderr: { text: '', truncated: false },
+ })
+ }
+
+ override start(): BashProcess {
+ throw new Error('tmux-context must never start a background task')
+ }
+}
+
+export const name = 'tmux-context-mock-bash'
+
+/** Register the deterministic `ctx.bash` executor for the fixture. */
+export function apply(ctx: Context): void {
+ ctx.plugin(TmuxMockBash)
+}
diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts
new file mode 100644
index 0000000000..2f6c7a6408
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts
@@ -0,0 +1,22 @@
+import type { Context } from 'cordis'
+import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
+
+/** Deterministic one-step adapter for the tmux-context Loader fixture. */
+class TmuxContextMockAdapter extends LlmAdapter {
+ async * stream(): AsyncIterable {
+ const text = 'tmux context sampled'
+ yield { type: 'block-start', index: 0, blockType: 'text' }
+ yield { type: 'text-delta', index: 0, text }
+ yield { type: 'block-end', index: 0, block: { type: 'text', text } }
+ yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
+ yield { type: 'finish', reason: { kind: 'stop' } }
+ }
+}
+
+export const name = 'tmux-context-mock-llm'
+export const inject = ['llm']
+
+/** Register the test-only `tmux-context-mock` adapter. */
+export function apply(ctx: Context): void {
+ ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter())
+}
diff --git a/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml
new file mode 100644
index 0000000000..419922a0e3
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml
@@ -0,0 +1,21 @@
+# Test-only composition: keep tmux-context opt-in while exercising its real Loader/app path.
+# A deterministic mock ctx.bash returns a fixed tmux reading, so the injected location
+# is stable without a real tmux server on the test host.
+- id: tmux-context-mock-llm
+ name: './tmux-context-mock-llm.ts'
+
+- id: bash
+ name: './tmux-context-mock-bash.ts'
+
+- id: tmux-context
+ name: '@deepseek-ai/dsh-tmux-context'
+
+- id: cli-agent
+ name: '@deepseek-ai/dsh-cli-demo'
+ config:
+ provider: tmux-context-mock
+ model: tmux-context-mock
+ persona: 'Test the tmux-context plugin.'
+ persistenceRoot: './.sessions'
+ persistenceCompression: 'none'
+ workspaceContext: false
diff --git a/examples/package.json b/examples/package.json
index 51fc48b8fa..b6f06057e3 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -10,6 +10,7 @@
"@deepseek-ai/dsh-acp-demo": "workspace:*",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:*",
"@deepseek-ai/dsh-app-boot": "workspace:*",
+ "@deepseek-ai/dsh-bash": "workspace:*",
"@deepseek-ai/dsh-bash-local": "workspace:*",
"@deepseek-ai/dsh-bash-sandbox": "workspace:*",
"@deepseek-ai/dsh-cli-demo": "workspace:*",
@@ -44,6 +45,7 @@
"@deepseek-ai/dsh-session-query-sqlite": "workspace:*",
"@deepseek-ai/dsh-session-telemetry-otel": "workspace:*",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*",
+ "@deepseek-ai/dsh-source-guard": "workspace:*",
"@deepseek-ai/dsh-spill-local": "workspace:*",
"@deepseek-ai/dsh-spill-policy": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
@@ -54,6 +56,7 @@
"@deepseek-ai/dsh-tasks-local": "workspace:*",
"@deepseek-ai/dsh-time-context": "workspace:*",
"@deepseek-ai/dsh-timeout-policy": "workspace:*",
+ "@deepseek-ai/dsh-tmux-context": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-tool-ask-user": "workspace:*",
"@deepseek-ai/dsh-tool-cordis": "workspace:*",
diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md
index ea8695d37e..eae5994fd8 100644
--- a/examples/tui-agent/README.md
+++ b/examples/tui-agent/README.md
@@ -29,7 +29,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio
dsh --resume