Merge remote-tracking branch 'origin/master' into worktree/context-source-cards

# Conflicts:
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/skill/tool-skill/README.i18n.yaml
This commit is contained in:
creatixchu
2026-08-05 14:11:29 +08:00
1062 changed files with 4994 additions and 3630 deletions

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
README.md: 583025e94d72c1ab03d282f8f4eb101c4e6f4740
README.zh.md: 3a082b423c1e4ab7e236179a3f502cd450b4904c
README.zh.md: ffffd97daa911636336ee9a515bf01bad33d31ae

View File

@@ -4,7 +4,7 @@
通过 JSON-RPC stdio 提供的仅面向自动化的 [ACPAgent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent智能体、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
此包package是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript文本记录回放、命令、模式、配置选择器、信息征集、推理、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。
此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript文本记录回放、命令、模式、配置选择器、信息征集、推理reasoning、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。
## 插件
@@ -47,7 +47,7 @@
#### 模型看到的内容
`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。
`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。
#### Token 影响
@@ -65,11 +65,11 @@
#### Token 影响
只有工具的结果会贡献 token。
只有所属工具的结果会贡献 token。
#### KV Cache 影响
随该工具的结果追加。
仅通过所属工具的结果追加。
## 已知限制与暂缓事项

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706
README.zh.md: c56543f26965effebaf020dd8d9d4ba130cd9b17
README.zh.md: 8d6e3b8d2fdd92faeb7b1e6e373a938ae2064cd4

View File

@@ -26,7 +26,7 @@
- **每次调用都 spawn不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`记录了两种已验证的有状态设计Claude Code 仅持久化 cwdCodex 使用 PTY exec 会话),供真实工作流需要时采用。
- **在受管进程组之上应用配置预算**`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算stderr 和后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
- **超时与取消分类**`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
- **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **后台进程**`start()` 会立即返回活动的 `BashProcess` 句柄不应用超时Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项id、所有权、轮询、通知都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。

View File

@@ -1,10 +1,11 @@
/**
* Local implementation of the bash executor seam over the subprocess
* seam. Each command runs as `bash -c` in a managed process group spawned
* through `ctx.subprocess`; this executor owns command defaulting, deadlines
* and cause classification, the model-friendly terminal environment, and the
* model-facing stdout/stderr merge for background reads. Execution policy
* belongs in `tools/pre-execute` or a sandboxing executor.
* seam. Public commands run as `bash -c` in a managed process group spawned
* through `ctx.subprocess`; subclasses may reuse the same mechanics with an
* explicit argv. This executor owns command defaulting, deadlines and cause
* classification, the model-friendly terminal environment, and the model-facing
* stdout/stderr merge for background reads. Execution policy belongs in
* `tools/pre-execute` or a sandboxing executor.
* @module @deepseek-ai/dsh-bash-local
*/
@@ -137,13 +138,18 @@ export class LocalBashExecutor extends BashExecutor {
}
}
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
/** Map one resolved bash spec and explicit argv onto a fully-specified subprocess spawn. */
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
private spawnSpec(
spec: BashExecSpec,
argv: readonly string[],
stdoutMaxBytes: number,
signal: AbortSignal | undefined,
): SubprocessSpawnSpec {
const collect = (maxBytes: number): SubprocessCollect =>
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
return {
argv: ['bash', '-c', spec.command],
argv,
cwd: spec.workdir,
stdio: {
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
@@ -171,9 +177,21 @@ export class LocalBashExecutor extends BashExecutor {
}
async run(spec: BashExecSpec): Promise<BashRunResult> {
return this.runArgv(spec, ['bash', '-c', spec.command])
}
/**
* Run an explicit argv with the foreground lifecycle, environment, output,
* timeout, and cancellation semantics of this executor. Subclasses use this
* after replacing the public command's shell argv at an execution boundary.
* @param spec - resolved execution settings and caller-owned command metadata.
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
* @returns the settled foreground result with collected output and cause facts.
*/
protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise<BashRunResult> {
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, spec.stdoutMaxBytes, d.signal))
const outcome = await handle.done
const collected = LocalBashExecutor.collected(handle)
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
@@ -190,8 +208,21 @@ export class LocalBashExecutor extends BashExecutor {
}
start(spec: BashExecSpec): BashProcess {
return this.startArgv(spec, ['bash', '-c', spec.command])
}
/**
* Start an explicit argv with the background lifecycle, environment, output,
* cancellation, and process-tree ownership semantics of this executor.
* Subclasses use this after replacing the public command's shell argv at an
* execution boundary.
* @param spec - resolved execution settings and caller-owned command metadata.
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
* @returns the live background handle; spawn rejection settles it as killed.
*/
protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess {
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal))
const collected = LocalBashExecutor.collected(running)
// A spawn failure produces no process output, so the subprocess service has nothing
@@ -216,12 +247,12 @@ export class LocalBashExecutor extends BashExecutor {
}
proc.exitCode = outcome.exitCode
proc.signal = outcome.signal
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
}, (error: unknown) => {
// Background spawn failures settle as killed and surface through the read path.
proc.status = 'killed'
spawnFailureNote = `spawn failed: ${String(error)}`
this.onProcessDone(proc, spawnFailureNote)
this.onProcessDone(proc, spawnFailureNote, true, error)
}),
readOutput: (): BashProcessRead => {
const out = collected.stdout.readFrom(stdoutOffset)
@@ -261,8 +292,10 @@ export class LocalBashExecutor extends BashExecutor {
* empty.
* @param _proc - the settled process handle.
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
* @param _spawnFailed - whether the subprocess promise rejected before a process started.
* @param _spawnError - the original spawn rejection reason, which may itself be undefined.
*/
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
}
export default LocalBashExecutor

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md
README.md: 035a8ad2401ca608d264049d454359eda7b2b9a7
README.zh.md: cee27a9baaa539ba07eb1d730ea9bef2004fbeeb
README.md: 2f69ea66251f00c74779f1decc69abc6003a4398
README.zh.md: fc4afb554442dfaf806292f30ddf9c495c427831

View File

@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal.
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned argv directly. With the shipped native runners, the inner Bash retains shell semantics and evaluates `BASH_ENV` only after the runner establishes confinement. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
| Mode | File effects |
|---|---|
@@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm
Semantics:
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
- **Runner attribution is conservative.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with positive provenance for provider argv[0]. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same provenanced `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
@@ -72,7 +72,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail.
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` argv[0] evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
#### Token effect
@@ -86,5 +86,5 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
- **A background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`.
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a provenanced synchronous `SubprocessService` throw instead fails `start()` immediately.
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.

View File

@@ -4,9 +4,9 @@
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`引号处理与结果分类 helper 保留在内部。
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方, spawn 返回的已包装argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,并直接 spawn 返回的 argv。使用随附的原生 runner 时,内层 Bash 保留 shell 语义,并且只在 runner 建立约束后才求值 `BASH_ENV`。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
| 模式 | 文件影响 |
|---|---|
@@ -17,7 +17,7 @@
语义:
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言即提供方在每次包装时加上的特征bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement``full`,或在较旧 Landlock ABI 上为 `partial`)。
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`已结算的后台进程会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
- **Runner 归因是保守的。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT``EACCES`,且带有明确指向提供方 argv[0] 的来源信息时,才会将拒绝归因于 runner。这样可以识别缺失的 runner、不可执行的 runner或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true``denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT``EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent智能体调用提供回退。已批准的升权只更改该策略的模式会话根目录仍然附着其上。`resolve()` 把策略带入 spec因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
- 进程机制spawn、进程组终止、输出收集spill、后台句柄、凭证清理继承自 [`dsh-bash-local`](../bash-local/)runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
@@ -72,7 +72,7 @@
#### 模型看到的内容
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 `ENOENT``EACCES` argv[0] 证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀
#### Token 影响
@@ -86,5 +86,5 @@
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;同步 `SubprocessService` 抛出带有来源信息的 `ENOENT``EACCES` 时,则会使 `start()` 立即失败
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。

View File

@@ -1,18 +1,61 @@
/**
* Internal shell-quoting and sandbox-result classification helpers.
* Internal sandbox-result classification helpers.
*
* @module @deepseek-ai/dsh-bash-sandbox/helpers
*/
import { accessSync, constants, statSync } from 'node:fs'
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
/** Whether the caller-owned spawn cwd can be entered. */
function isUsableWorkdir(path: string): boolean {
try {
if (!statSync(path).isDirectory()) return false
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}
/**
* Quote one string as a single-quoted POSIX shell word.
* @param text - raw argv element to preserve through the outer shell parse.
* @returns the quoted shell word.
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
* after independently ruling out the caller-owned cwd. A supplied error path
* must exactly identify the runner; without one, the syscall must. With a
* usable cwd, these codes describe resolution or execute permission for that
* argv[0] or its shebang interpreter.
* The workdir is checked at classification time, not atomically with spawn;
* concurrent path replacement may change attribution but cannot permit an
* unconfined execution.
* @param error - the original spawn rejection.
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
* @returns whether the rejection has executable-specific runner evidence.
*/
export function shellQuote(text: string): string {
return `'${text.replaceAll("'", String.raw`'\''`)}'`
export function isRunnerSpawnFailure(
error: unknown,
runnerProgram: string | undefined,
workdir: string,
): boolean {
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
if (typeof error !== 'object' || error === null) return false
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
if (typeof syscall !== 'string') return false
const exactSyscall = `spawn ${runnerProgram}`
if (path === undefined) return syscall === exactSyscall
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
return syscall === 'spawn' || syscall === exactSyscall
}
/** Fatal runner evidence retained for infrastructure-error detail. */
interface RunnerFailureMatch {
/** The original stderr line that matched a fatal signature. */
detail: string
}
/**
@@ -26,13 +69,37 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin
}
/**
* Classify a failed run against the selected backend's runner-failure dialect.
* @param result - settled foreground run.
* @param signatures - case-insensitive runner-failure substrings from the active wrap.
* @returns whether the failed run matches that runner-failure dialect.
* Classify one settled process against the selected backend's structured
* runner-failure rules. Each rule requires a nonzero exit, its optional
* exit-code gate, and a fatal signature on one stderr line after exact
* informational lines are excluded.
* @param exitCode - process exit code; null means signal termination.
* @param stderr - collected stderr text, left unchanged.
* @param rules - structured runner-failure rules from the active wrap.
* @returns the first matching fatal line, or undefined when evidence is insufficient.
*/
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
return matchesSignature(result.exitCode, result.stderr.text, signatures)
export function classifyRunnerFailure(
exitCode: number | null,
stderr: string,
rules: readonly RunnerFailureRule[],
): RunnerFailureMatch | undefined {
if (exitCode === null || exitCode === 0) return undefined
const lines = stderr.split(/\r?\n/)
for (const rule of rules) {
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
// An empty or whitespace-only substring is not meaningful runner evidence.
// Ignore it while keeping any valid signatures beside it active.
const fatalSignatures = rule.fatalSignatures
.filter(signature => signature.trim().length > 0)
.map(signature => signature.toLowerCase())
for (const line of lines) {
const lowered = line.toLowerCase()
if (informationalLines.has(lowered)) continue
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
}
}
return undefined
}
/**

View File

@@ -1,21 +1,29 @@
/**
* Sandbox-consuming bash executor. It wraps the exact local bash argv through
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
* mode, enforcement, and denial facts. Runner failure means the command never
* ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
* processes carry `runnerFailed`. The tool owns approval and passes a complete
* per-call policy.
* mode, enforcement, and denial facts. Positive runner-launch evidence means
* the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
* background processes carry `runnerFailed`; other spawn rejections retain
* local-executor semantics. The tool owns approval and passes a complete per-call policy.
* @module @deepseek-ai/dsh-bash-sandbox
*/
import { Context } from 'cordis'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type {
ConfinedArgv,
ConfinedSandboxMode,
RunnerFailureRule,
SandboxEnforcement,
SandboxExecutionPolicy,
SandboxMode,
SandboxPolicy,
} from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
/**
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
@@ -51,7 +59,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
mode: ConfinedSandboxMode
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
runnerProgram: string | undefined
workdir: string
}>()
constructor(ctx: Context, config: Config) {
@@ -83,11 +93,22 @@ export class SandboxBashExecutor extends LocalBashExecutor {
return { ...result, sandbox: { mode, denied: false } }
}
const confined = this.confine(spec.command, { ...policy, mode })
const result = await super.run({ ...spec, command: confined.command })
// Runner failure outranks denial because the command did not run. Throw the
// same fail-closed error as confine-time discovery with the first stderr line.
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
let result: BashRunResult
try {
result = await this.runArgv(spec, confined.argv)
} catch (error) {
// An upstream abort remains cancellation even when it prevents spawn.
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
if (runnerFailure !== undefined) {
throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
}
@@ -96,11 +117,29 @@ export class SandboxBashExecutor extends LocalBashExecutor {
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') return super.start(spec)
// Install facts synchronously; promise settlement cannot run before start() returns.
// Once startArgv returns, install facts synchronously; promise settlement
// cannot run before start() returns.
const confined = this.confine(spec.command, { ...policy, mode })
const proc = super.start({ ...spec, command: confined.command })
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
let proc: BashProcess
try {
proc = this.startArgv(spec, confined.argv)
} catch (error) {
// LocalSubprocessService reports provenanced ENOENT/EACCES through async
// `done` rejection; this covers alternatives that throw that shape synchronously.
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
const { enforcement, denialSignatures, runnerFailureRules } = confined
this.processFacts.set(proc, {
mode,
enforcement,
denialSignatures,
runnerFailureRules,
runnerProgram: confined.argv[0],
workdir: spec.workdir,
})
return proc
}
@@ -108,12 +147,15 @@ export class SandboxBashExecutor extends LocalBashExecutor {
* Stamp per-process sandbox facts before `done` settles. Full-access processes
* have no facts; signal deaths are not denials.
*/
protected override onProcessDone(proc: BashProcess, stderr: string): void {
protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
const facts = this.processFacts.get(proc)
if (facts !== undefined) {
this.processFacts.delete(proc)
// Runner failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
// A rejected spawn never started the confined launch. Otherwise runner
// failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = spawnFailed
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
@@ -121,30 +163,19 @@ export class SandboxBashExecutor extends LocalBashExecutor {
...(runnerFailed ? { runnerFailed } : {}),
}
}
super.onProcessDone(proc, stderr)
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
}
/**
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
* exact `['bash', '-c', command]` argv this executor would spawn, get back
* the confined argv, and re-assemble it into the `exec …` command string
* the inherited spawn path runs (the outer `bash -c` the subprocess service spawns
* `exec`s into the runner, so no extra shell lingers). Provider errors
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
* Wrap one shell command via the `ctx.sandbox` provider. Provider errors
* propagate unchanged; the returned argv is handed directly to the local
* executor's subprocess path.
* @param command - shell source for the confined inner `bash -c`.
* @param policy - resolved confined execution policy.
* @returns the provider's exact argv and settlement-classification facts.
*/
private confine(command: string, policy: SandboxPolicy): {
command: string
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureSignatures: readonly string[]
} {
const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
return {
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
enforcement: confined.enforcement,
denialSignatures: confined.denialSignatures,
runnerFailureSignatures: confined.runnerFailureSignatures,
}
private confine(command: string, policy: SandboxPolicy): ConfinedArgv {
return this.ctx.sandbox.confine(['bash', '-c', command], policy)
}
}

View File

@@ -0,0 +1,270 @@
/**
* Deterministic real-process proofs for runner classification: the real local
* provider and sandbox bash executor exercise direct runner-spawn failures
* and a POSIX fake Landlock launcher that prints its notice before exec.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
const FATAL_PREFIX = 'landlock-run: '
const FATAL = `${FATAL_PREFIX}landlock ruleset error: Invalid argument`
const contexts: Context[] = []
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
/** Write a fake native launcher that reports partial enforcement, then execs or fails. */
async function fakeLauncher(fatalExit?: number): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-'))
tempDirs.push(dir)
const launcher = join(dir, 'landlock-run')
const fatalBranch = fatalExit === undefined ? '' : `printf '%s\\n' '${FATAL}' >&2\nexit ${fatalExit}\n`
await writeFile(launcher, `#!/bin/sh
while [ "$#" -gt 0 ]; do
case "$1" in
--ro|--rw) shift 2 ;;
--) shift; break ;;
*) printf '%s\\n' '${FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;;
esac
done
printf '%s\\n' '${NOTICE}' >&2
${fatalBranch}exec "$@"
`, { mode: 0o755 })
return launcher
}
async function setup(fatalExit?: number): Promise<SandboxBashExecutor> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {})
const sandbox = ctx.sandbox as LocalSandboxProvider
sandbox.internals = {
platform: 'linux',
probeBwrap: () => false,
probeLandlock: () => 'partial',
landlockLauncher: await fakeLauncher(fatalExit),
}
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
return ctx.bash as SandboxBashExecutor
}
async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecutor> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {
runnerCommand: [runner],
runnerFailureSignatures: ['configured-runner: fatal'],
})
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
return ctx.bash as SandboxBashExecutor
}
describe('partial Landlock runner-failure classification', () => {
it.each(['missing', 'unexecutable', 'missing-interpreter'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-'))
tempDirs.push(dir)
const runner = join(dir, `${kind}-runner`)
if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 })
if (kind === 'missing-interpreter') {
await writeFile(runner, '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
}
const bash = await setupConfiguredRunner(runner)
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(runner)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`)
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
})
it.each(['bare-name', 'relative'] as const)(
'classifies a %s runner whose shebang interpreter is missing',
async (form) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-argv-form-sandbox-runner-'))
tempDirs.push(dir)
const filename = 'missing-interpreter-runner'
const runner = form === 'bare-name' ? filename : `./${filename}`
await writeFile(join(dir, filename), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
const bash = await setupConfiguredRunner(runner)
const request = form === 'bare-name'
? { command: 'true', env: { PATH: dir } }
: { command: 'true', workdir: dir }
const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
// Empirically, Darwin and Linux Node 24 preserve the passed bare/relative
// argv[0] in this spawn error rather than resolving it to an absolute path.
expect((error as Error).message).toContain(`spawn ${runner} ENOENT`)
const task = bash.start(bash.resolve(request))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`)
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
},
)
it('keeps a real malformed executable ordinary across no-shebang spawn behavior', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-malformed-sandbox-runner-'))
tempDirs.push(dir)
const runner = join(dir, 'malformed-runner')
await writeFile(runner, 'not a native executable or shebang script\n', { mode: 0o755 })
const bash = await setupConfiguredRunner(runner)
const request = { command: 'true' }
// Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a
// no-shebang executable through /bin/sh (Linux). Neither path supplies the
// provenanced ENOENT/EACCES evidence required for runner attribution.
const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
if (foreground instanceof Error) {
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect((foreground as { path?: unknown }).path).toBeUndefined()
let background: unknown
try {
bash.start(bash.resolve(request))
} catch (error) {
background = error
}
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect((background as { path?: unknown }).path).toBeUndefined()
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
} else {
expect(foreground).toMatchObject({
exitCode: 127,
signal: null,
sandbox: { mode: 'read-only', denied: false, enforcement: 'full' },
})
expect((foreground as { stderr: { text: string } }).stderr.text.length).toBeGreaterThan(0)
const background = bash.start(bash.resolve(request))
await background.done
expect(background.status).toBe('completed')
expect(background.exitCode).toBe(127)
expect(background.signal).toBeNull()
expect(background.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
const output = background.readOutput().delta
expect(output.startsWith('[stderr]\n')).toBe(true)
expect(output.length).toBeGreaterThan('[stderr]\n'.length)
expect(output).not.toContain('spawn failed:')
}
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
})
it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])(
'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line',
async (exitCode) => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
},
)
it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => {
const bash = await setup(exitCode)
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n${FATAL}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => {
const bash = await setup(LAUNCHER_FAILURE_EXIT)
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(`Runner failure: ${FATAL}`)
expect((error as Error).message).not.toContain(NOTICE)
})
it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
})
it('applies the same evidence rule to notice-only background exits', async () => {
const bash = await setup()
for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) {
const task = bash.start(bash.resolve({ command }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
}
})
it('classifies a background notice plus child Permission denied as denial', async () => {
const bash = await setup()
const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
})
it('makes a background fatal line outrank denial text after the notice', async () => {
const bash = await setup(LAUNCHER_FAILURE_EXIT)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'partial',
runnerFailed: true,
})
const output = task.readOutput().delta
expect(output).toContain(NOTICE)
expect(output).toContain(FATAL)
})
})

View File

@@ -5,7 +5,7 @@
* the Unix denial signature used by the classifier without requiring a real sandbox runner.
*/
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
@@ -16,7 +16,8 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy }
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
@@ -30,12 +31,19 @@ interface ConfineCall {
/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
const RUNNER_FAILURE = ['fake-runner: '] as const
/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const
/** Provider argv[0] forms that all share the caller-owned cwd spawn precondition. */
const RUNNER_FORMS = [
['absolute', process.execPath],
['bare', 'node'],
['relative', './sandbox-runner'],
] as const
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
const passthrough = (argv: readonly string[]): ConfinedArgv =>
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })
/**
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
@@ -90,15 +98,49 @@ describe('the provider hand-off', () => {
}])
})
it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
// The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
// the sentinel only prints if the executor spawned the WRAPPED argv.
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
it('hands the provider\'s returned argv directly to ctx.subprocess.spawn', async () => {
const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"']
const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
expect(result.stdout.text).toBe('1')
expect(spawn).toHaveBeenCalledTimes(1)
expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('starts a non-Bash runner before the confined inner Bash evaluates BASH_ENV', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-env-order-'))
const hook = join(dir, 'hook.sh')
const order = join(dir, 'order.txt')
writeFileSync(hook, 'printf "hook\\n" >> "$DSH_ORDER_FILE"\n')
const runnerScript = [
'const { appendFileSync } = require("node:fs");',
'const { spawnSync } = require("node:child_process");',
'appendFileSync(process.env.DSH_ORDER_FILE, "runner\\n");',
'const child = spawnSync(process.argv[1], process.argv.slice(2), { env: process.env, stdio: "inherit" });',
'process.exit(child.status ?? 125);',
].join('')
const { bash } = await setup({}, argv => ({
argv: [process.execPath, '-e', runnerScript, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
try {
const result = await bash.run(bash.resolve({
command: 'true',
env: { BASH_ENV: hook },
dshEnv: { DSH_ORDER_FILE: order },
}))
expect(result.exitCode).toBe(0)
expect(readFileSync(order, 'utf8')).toBe('runner\nhook\n')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
const { bash, calls } = await setup({ mode: 'workspace-write' })
const result = await bash.run(bash.resolve({ command: 'true' }))
@@ -120,9 +162,6 @@ describe('the provider hand-off', () => {
expect(calls).toHaveLength(2)
})
it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => {
expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`)
})
})
describe('fail closed', () => {
@@ -132,6 +171,120 @@ describe('fail closed', () => {
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
})
it('preserves an already-aborted foreground call as cancellation', async () => {
const { bash } = await setup()
const controller = new AbortController()
const reason = new Error('caller cancelled before spawn')
controller.abort(reason)
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
})
it.each(RUNNER_FORMS)(
'keeps an invalid workdir ordinary with the %s provider-runner form',
async (_form, runner) => {
const { bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
.catch((error: unknown) => error)
expect(failure).toMatchObject({ code: 'ENOENT' })
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
},
)
it('keeps an invalid workdir ordinary when danger-full-access bypasses the provider', async () => {
const { bash } = await setup({ mode: 'danger-full-access' })
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
.catch((error: unknown) => error)
expect(failure).toMatchObject({ code: 'ENOENT' })
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
it('keeps Node-shaped synchronous ENOEXEC ordinary in run() and start()', async () => {
const runner = join(spillDir, 'malformed-runner')
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn' })
})
const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((error: unknown) => error)
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
let background: unknown
try {
bash.start(bash.resolve({ command: 'true' }))
} catch (error) {
background = error
}
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
})
it('classifies a synchronous SubprocessService EACCES with exact runner provenance', async () => {
const runner = join(spillDir, 'unexecutable-runner')
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
// This pins an alternative SubprocessService's synchronous seam, not the
// shipped local behavior.
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES', syscall: 'spawn', path: runner })
})
await expect(bash.run(bash.resolve({ command: 'true' })))
.rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(() => bash.start(bash.resolve({ command: 'true' })))
.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
})
it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => {
const runner = './sandbox-runner'
const { ctx, bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
const workdir = join(parent, 'missing')
const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure })
try {
let thrown: unknown
try {
bash.start(bash.resolve({ command: 'true', workdir }))
} catch (error) {
thrown = error
}
expect(thrown).toBe(failure)
expect(thrown).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
})
describe('danger-full-access', () => {
@@ -233,15 +386,134 @@ describe('classifyDenial', () => {
})
})
describe('isRunnerSpawnFailure', () => {
it.each(['EACCES', 'ENOENT'])(
'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated',
(code) => {
const runner = join(spillDir, 'runner')
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(true)
},
)
it.each(['ENOEXEC', 'ENOTDIR', 'EPERM'])(
'keeps unproven executable code %s ordinary despite synthetic argv[0] fields',
(code) => {
const runner = join(spillDir, 'runner')
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(false)
},
)
it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => {
const missingWorkdir = join(spillDir, 'missing-workdir')
for (const [, runner] of RUNNER_FORMS) {
const error = Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
expect(isRunnerSpawnFailure(error, runner, missingWorkdir)).toBe(false)
}
const fileWorkdir = join(spillDir, 'not-a-workdir')
writeFileSync(fileWorkdir, '')
const error = Object.assign(new Error('spawn failed'), { code: 'ENOTDIR', syscall: 'spawn node', path: 'node' })
expect(isRunnerSpawnFailure(error, 'node', fileWorkdir)).toBe(false)
})
it('rejects resource, non-spawn, mismatched-program, and unstructured failures', () => {
const missingRunner = join(spillDir, 'definitely-missing-runner')
const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) =>
Object.assign(new Error('spawn failed'), { code, syscall, path })
const spawnErrorWithoutPath = (syscall: string) =>
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall })
expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError(2), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'open'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn other-runner'), missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false)
expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
})
it('accepts only syscall provenance compatible with the exact runner program', () => {
const runner = join(spillDir, 'runner with spaces')
const spawnError = (syscall: string, path?: string) =>
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path })
expect(isRunnerSpawnFailure(spawnError('spawn', runner), runner, process.cwd())).toBe(true)
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`, runner), runner, process.cwd())).toBe(true)
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`), runner, process.cwd())).toBe(true)
expect(isRunnerSpawnFailure(spawnError('spawn other-runner', runner), runner, process.cwd())).toBe(false)
})
})
describe('classifyRunnerFailure', () => {
it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
it('ignores empty and whitespace-only fatal signatures instead of treating exit status or notice text as evidence', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: ['', ' ', '\t'] }]
expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined()
expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined()
})
it('keeps valid fatal signatures active beside an ignored empty entry', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const fatal = 'landlock-run: ruleset creation failed'
const rules = [{
allowedExitCodes: [125],
fatalSignatures: ['', ' ', 'landlock-run: '],
informationalLines: [notice],
}]
expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal })
})
it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }]
expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined()
expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules))
.toEqual({ detail: `${notice}: extra detail` })
expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules))
.toEqual({ detail: 'landlock-run: exec failed: No such file or directory' })
})
it.each([
'landlock-run: usage error: missing `-- <argv>...` command',
'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)',
'landlock-run: cannot open rule path: /gone: No such file or directory',
'landlock-run: landlock ruleset error: Invalid argument',
'landlock-run: exec failed: Permission denied',
'landlock-run: out of memory',
'landlock-run: future fatal diagnostic',
])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => {
const rules = [{
allowedExitCodes: [125],
fatalSignatures: ['landlock-run: '],
informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
}]
expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal })
})
})
describe('result facts', () => {
it.each([126, 127])('keeps a successfully launched wrapped child exit %i as an ordinary outcome', async (exitCode) => {
const { bash } = await setup({}, argv => ({
argv: ['env', ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
const { bash } = await setup()
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
@@ -253,25 +525,66 @@ describe('result facts', () => {
})
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
})
describe('background sandbox facts', () => {
it('stamps facts and releases accounting when background spawn fails', async () => {
const { bash } = await setup()
const missingWorkdir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')), 'missing')
const task = bash.start(bash.resolve({ command: 'true', workdir: missingWorkdir }))
it.each(RUNNER_FORMS)('keeps an invalid-workdir rejection ordinary for the %s provider-runner form', async (_form, runner) => {
const { bash } = await setup({}, argv => ({
argv: [runner, ...argv],
enforcement: 'full',
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain('spawn failed:')
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
})
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {
const { ctx, bash } = await setup()
const emptyReader: SubprocessOutputReader = {
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
}
vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
pid: -1,
stdin: undefined,
stdout: undefined,
stderr: undefined,
collected: { stdout: emptyReader, stderr: emptyReader },
// Arbitrary subprocess providers can reject without a value; that edge is the point of this test.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
done: Promise.reject(undefined),
terminate: vi.fn(),
waitForExit: async () => true,
} satisfies SubprocessHandle)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain('spawn failed:')
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
expect(task.readOutput().delta).toContain('spawn failed: undefined')
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'full',
})
})
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
@@ -284,7 +597,7 @@ describe('background sandbox facts', () => {
it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
// The wrap's runner prefix on a failed run means the SANDBOX broke and
// the command never ran — the late twin of the confine-time throw, with
// the runner's own first stderr line carried as the cause.
// the matched fatal stderr line carried as the cause.
const { bash } = await setup()
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
@@ -315,7 +628,7 @@ describe('background sandbox facts', () => {
let call = 0
const { bash } = await setup({}, (argv) => {
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE }
})
const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
const quick = bash.start(bash.resolve({ command: 'true' }))

View File

@@ -1,6 +1,6 @@
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -76,6 +76,32 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
})
it('evaluates BASH_ENV only after Seatbelt confines the inner Bash', async () => {
const workdir = await tempDir(homedir())
const outside = await tempDir(homedir())
const hook = join(workdir, 'bash-env-hook.sh')
const insideProbe = join(workdir, 'hook-ran.txt')
const outsideProbe = join(outside, 'escaped.txt')
await writeFile(hook, [
'printf hook > "$DSH_BASH_ENV_INSIDE"',
'printf escaped > "$DSH_BASH_ENV_OUTSIDE"',
'',
].join('\n'))
const bash = await sandboxedBash(workdir, 'workspace-write')
await bash.run(bash.resolve({
command: 'true',
env: { BASH_ENV: hook },
dshEnv: {
DSH_BASH_ENV_INSIDE: insideProbe,
DSH_BASH_ENV_OUTSIDE: outsideProbe,
},
}))
expect(readFileSync(insideProbe, 'utf8')).toBe('hook')
expect(existsSync(outsideProbe)).toBe(false)
})
it('classifies a background denial once the task settles', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
README.md: d7bf746969f52000fe298b65b995b7c631d8001c
README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c
README.zh.md: 4eef38e436cb0a4bcb3505aeb228d18c4a8c170b

View File

@@ -4,7 +4,7 @@
**bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么即运行前台命令与启动后台进程但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。
本包package是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换):
| 包 | 职责 |
|---|---|
@@ -33,7 +33,7 @@
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult``start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
`stdin` 与普通 `env` 由同进程插件hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR``CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选缺失表示没有输入overlay。详见 [bash-stdin-env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
`stdin` 与普通 `env` 由同进程插件hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR``CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选缺失表示没有输入overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
## 模型体验

View File

@@ -16,10 +16,10 @@ import type { BashProcess } from '@deepseek-ai/dsh-bash'
*/
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
// infrastructure-failure outcome, then map spawn failures and
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
// failure with a signal-less kill and a runner failure with an ordinary
// wrapper exit; real nonzero command exits must remain `completed`.
// infrastructure-failure outcome, then map it to task `failed`. Restricted
// runner failures expose sandbox.runnerFailed, but unconfined spawn failures
// still alias a signal-less kill; real nonzero command exits must remain
// `completed`.
if (proc.status === 'killed') {
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
}

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
README.md: 7f780092af9bc7079cc5080c06e986bef2dfdbce
README.zh.md: 62c037977115d33b834fe60b042431e44d208524
README.zh.md: 288982b1247f93fa1e8578a9ece2fcf8ed86666d

View File

@@ -10,7 +10,7 @@ locale 插件LocaleService——浏览器 locale 偏好(`zh``en`,以 `
#### KV Cache 影响
无;该包package既不组装也不发送提供方请求。
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/schema-form/README.md
README.md: 5dcef89cbffc8b03c3f2d874e870fa9767360d3c
README.zh.md: a82acb7d85005da25858fb17cf42b49f06ae59db
README.zh.md: ebaa9e0d0a134a6fade5729215168a1a47fd375c

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
面向 settings 编辑器的 schema草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema`schema.toJSON()` 的 ref 信封);`rehydrateSchema``new Schema(json)` 将其还原rehydrate为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件Models 页围绕它在此探测到的字段手写自己的卡片);该包package不含任何 React也不做任何渲染。
面向 settings 编辑器的 schema草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema`schema.toJSON()` 的 ref 信封);`rehydrateSchema``new Schema(json)` 将其还原rehydrate为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件Models 页围绕它在此探测到的字段手写自己的卡片);该包不含任何 React也不做任何渲染。
## 契约
@@ -20,4 +20,4 @@
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback因此 schema 信封是可执行内容,而非惰性数据。这只有在信封来自提供该页面的同一 host 时才可接受;面向浏览器的 schema 协议应当传递客户端无法执行的描述,此项与 settings seam 的[协议边界工作](../../settings/settings/README.md#known-limitations-and-deferred-work)一并暂缓。
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做。
- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。
- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
README.md: c892f2f244d7924014ad1b4d6e9fe16ff4e044e4
README.zh.md: ed607de783e833eed94fba09bc20c74375711a4f
README.zh.md: e5d109af8ca94515e0b574c62c57968796af5ce8

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpacematchEnter 裁决钩子的 `/` 命令 source、三型派发executepopupSelectleadingInput以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpacematchEnter 裁决钩子的 `/` 命令 source、三型派发executepopupSelectleadingInput以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)``decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-louddecoration装饰则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claimspace / 带参 enter与生命周期记账被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput注册了 `CommandUiSpec` 的是 popupSelect其余全部是 execute。
@@ -22,5 +22,5 @@
## 已知限制与暂缓事项
- **popupSelect 壳还没有已上架的业务消费**模型选择host `selectModel`)是设计的参照用例,将随其自身的功能工作落地;在此之前,壳只由包测试演练。
- **popupSelect 壳还没有已上架的业务消费**模型选择host `selectModel`)是设计的参照用例,将随其自身的功能工作落地;在此之前,壳只由包测试演练。
- **脱离会话后detached result 的 notice 回退到 console**fire-and-forget 路径经 `SessionInput.notify` 把结果送到触发会话的编辑器会话拆除后console 输出行是仅剩的呈现面。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
README.md: 3da9d97c801a0a742de2601e5261c09ba193cf33
README.zh.md: c2474fc6ef8d0c990da4b4eaff79d56baf3180cf
README.zh.md: 8a4c01394508d9ceb3eabb6cd38ad58abd7f0e38

View File

@@ -2,18 +2,18 @@
[English](README.md) | 中文
Goal 面插件(浏览器半件`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片order 10位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear`goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
Goal 面插件(浏览器端部分`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片order 10位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear`goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
`/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
## Model Experience
## 模型体验
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。
#### KV Cache effect
#### KV Cache 影响
除 goal 变更自身的上下文事件(如同任何消息一样追加在日志尾部)外无额外影响。
## Known Limitations and Deferred Work
## 已知限制与暂缓事项
- **只反映持久 phase** —— 投影值有意省略进程本地的 activationarmed/disarmed条带无法区分 active-but-disarmed 与 armed 状态resume RPC 重新武装。host 活值通道待出现真实消费方后再议。
- **只反映持久 phase**——投影值有意省略进程本地的 activationarmed/disarmed条带无法区分 active-but-disarmed 与 armed 状态resume 通过 RPC 重新置为 armed 状态。host 活值通道待出现真实消费方后再议。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba
README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3
README.zh.md: a24dfa4d4eeb28fdc8df1d21daa3f6e9476d0062

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭。关闭的侧边栏仍保留 56px 控制,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id首个会话保持关闭显式打开详情栏的操作会使用契约默认宽度返回同一会话时恢复其未改变的宽度选择不同会话时详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的宽度偏好。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id首个会话保持关闭显式打开详情栏的操作会使用契约默认宽度返回同一会话时恢复其未改变的宽度选择不同会话时详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`;注册方通过标准钩子获取业务数据,并从各自的 inject 接口获取操作。
`/client` 导出表层包含插件主体(`apply``inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。
@@ -19,5 +19,5 @@ AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionPr
## 已知限制与暂缓事项
- **面板几何信息是瞬时状态**:重新加载会恢复侧边栏默认值,并使详情栏保持关闭;在不同会话 id 之间切换同样会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。
- **让步链自动关闭通过推导零宽度实现,不会改动首选宽度**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。
- **让步链自动关闭通过推导零宽度实现,不会改动宽度偏好**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。
- **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
README.md: 27fb7b936b796b956f7348fa776856180350bb56
README.zh.md: 9cc6b04ef2e7ba24fb8fc3f6d5456bf88f0652fe
README.zh.md: 06dbcc21c31c8ed9fd72d3c07d43c8db1bcca0bb

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
模型选择插件(浏览器侧):**两个入口共用一份 per-session 目录**,由 `ModelService``ctx.models`)持有。对于普通会话,`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方模型推理reasoning目标是两个入口共同回显的唯一事实`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。提供方元数据失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent智能体的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService``ctx.models`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方模型推理reasoning目标是两个入口共同回显的唯一事实`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。提供方元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent智能体的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService``ModelDirectory` 及其状态形状、坑位注入面类型。
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService``ModelDirectory` 及其状态形状、slot 注入面类型。
## 模型体验
间接影响,经仅普通会话用的 `session.selectModel` RPC,两个入口都会提交提供方/模型/推理强度目标Host 会在下一次提示词组装边界对该目标进行快照,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化,且菜单交互不会添加提示词内容。
间接影响。两个入口都通过仅供普通会话使用的 `session.selectModel` RPC 提交提供方/模型/推理强度目标Host 会在下一次提示词组装边界对该目标进行快照,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化菜单交互不会添加提示词内容。
#### KV Cache 影响
@@ -16,6 +16,6 @@
## 已知限制与暂缓事项
- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent没有可入会话创建的 Draft 期模型选择subagent 继续执行也有意不公开独立更改模型目标的契约。
- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent没有可入会话创建的草稿阶段模型选择subagent 继续执行也有意不公开独立更改模型目标的契约。
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id目录查询或确切模型元数据查询失败的提供方以不可选失败行列出重新加载前保持原样。
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: 937b8e6bf9b41049f359d702eb3ac2dc11bf0767
README.zh.md: 37d8642e8d6d52a2d95e86207649b7a6ce3e8246
README.zh.md: a467b65da0bf0a38951cd11dba9ec54a2b03c08e

View File

@@ -16,11 +16,11 @@
#### KV Cache 影响
无;该包package既不组装也不发送提供方请求。
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile却刻意不清除那条派生凭据重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
- **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-permission/README.md
README.md: 742e82d767152073ab963dc74c0565d6e8f8e5c4
README.zh.md: e4b39567e4e39d74fd4d527ed2fcfed8d5318a59
README.zh.md: 70bbbb2d14358cbe52a6fc27deb7ce01d5f3679b

View File

@@ -6,16 +6,16 @@
当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**`ctx.command.decorate`。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 activekebab-case 预设名渲染为 Title Case 标签(`workspace-write``Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select因此两个当前会话界面共享同一读源与同一写路径推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。
`/client` 导出面为插件本体(`apply`/`inject`)。
`/client` 导出面为插件本体(`apply``inject`)。
## Model Experience
## 模型体验
通过两个界面写入的权限事实间接影响Settings 行使未来会话带着全量值旋钮事件(`permission/preset``sandbox/mode``approval/policy`)启动,而 `/permission` 选择框切换当前会话时会追加相同的事实;这些事件决定后续工具调用解析到的沙箱模式与审批策略,选择框交互本身不添加任何提示词内容。
#### KV Cache effect
#### KV Cache 影响
无直接失效;请求前缀的变化由旋钮消费方自行承担。
## Known Limitations and Deferred Work
## 已知限制与暂缓事项
- **Settings 行仅在 Web 中可用**:非 Web 客户端仍可通过 `/permission` 切换当前会话,但不会获得这项浏览器贡献。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md
README.md: fcc4fbab4fbe1a8cc27119366b21ef55c669ba30
README.zh.md: b618199616e45f69d62f3507c96d367bb3b9909f
README.zh.md: f512d40568058ef061c6f262eadb20acda78cb64

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占会话声明的 `conversation.input.plan`(位于 access 模式控件右侧node 侧是空 applyroster 行。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。
Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占会话声明的 `conversation.input.plan`实例 seat(位于 access 模式控件右侧node 侧是空 applyroster 行。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。
plan mode 经 `/plan` 命令路径进入:用户可以从 composer 的 `+` Command 菜单选择 Plan也可以输入 `/plan`,而本包package不渲染未激活态 plan 控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染 warn 色的 "Plan ×" 状态按钮,该按钮经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host或尚无会话的 Draft不显示任何内容。plan mode 为有效目标期间composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染owner 提供的 placeholder 优先)。
plan mode 经 `/plan` 命令路径进入:用户可以从 composer 的 `+` Command 菜单选择 Plan也可以输入 `/plan`,而本包不渲染未激活态 plan 控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染 warn 色的 "Plan ×" 状态按钮,该按钮经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host或尚无会话的 Draft不显示任何内容。plan mode 为有效目标期间composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染owner 提供的 placeholder 优先)。
chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障以内联错误呈现chip 保持显示直至投影确认退出。
@@ -14,12 +14,12 @@ chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`m
间接地,通过 chip 派发的 `/plan off` 命令行:`@deepseek-ai/dsh-plan-mode` 拥有该命令行驱动的模型可见 policy 段、退出工具 schema 与已记录状态,本包只渲染投影并发送用户同样可以手敲的内容。
#### KV 缓存效应
#### KV Cache 影响
进入或离开 plan mode 会改变活跃的 `plan:policy` 系统提示词段因此改变请求前缀chip 本身不添加任何提示词内容。
## 已知局限与延后工作
- **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。
- **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。
- **Plan mode 是引导而非执行沙箱**需要强制只读规划的部署必须组合独立的沙箱与审批策略。
- **chip 属于默认编辑器**待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。
- **无未激活态 plan 控件**——入口使用共享 Command source有能力但 mode 未激活的会话在工具行不显示 plan 入口。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-question/README.md
README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c
README.zh.md: 3c2b12b30dd2858c7b8f99193829c3274f3f8228
README.zh.md: 6344327d268f1d0c2ec0aaaf29657ea040e51691

View File

@@ -4,9 +4,9 @@
Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected``custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定超长的详情与选项共享内部滚动区。单选选项会立即前进所有问题均已回答或跳过后Enter 会提交IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected``custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。限高卡片保持标题、导航与提交动作固定超长的详情与选项共享内部滚动区。单选选项会立即前进所有问题均已回答或跳过后Enter 会提交IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— `dsh-plan-mode``exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip`Chat about it``ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review`——由 `dsh-plan-mode``exit_plan_mode` 审阅上设置——采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip`Chat about it``ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形——没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定——都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权HTTP 交付成功不会在本地移除待处理状态。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
README.md: 14c78c83467313a6efa7033c31fb9c9b1cd94e0c
README.zh.md: 8831842d03db4572f1dca5547b84c0d8a3be8f2d
README.zh.md: 9ca4810faccaa119bb194c0e41bb8232b6aff630

View File

@@ -12,7 +12,7 @@
#### KV Cache 影响
无;该包package既不组装也不发送提供方请求。
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
README.md: fc83ae47dc83e72d60f382892aa678989902d217
README.zh.md: 60f2c258acdb7e19148e05f19061e0e3f2c28ee7
README.zh.md: e103db812d2a21f7f211bc843ec0cd31d1dc2c1e

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
skill技能引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续子代理在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flightscope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤pick 一个候选会把字面文本 `/name ` 经 slash 线落进草稿(决策 21 的纯文本引用source 的 `codec` 拥有该引用的两种投影:`clipboardText``/name``serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace``matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
skill技能引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flightscope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤pick 一个候选会把字面文本 `/name ` 经 slash 流水线落进草稿(决策 21 的纯文本引用source 的 `codec` 拥有该引用的两种投影:`clipboardText``/name``serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace``matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
`skill.list` 失败时 `candidates` 抛出异常slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pendingready 状态。
@@ -14,7 +14,7 @@ skill技能引用 source 的浏览器端:把 `/` 触发的 `skill` sourc
#### 模型看到的内容
被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21纯文本`<skill>` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且确定:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。
被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21纯文本`<skill>` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且具有非确定:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。
#### Token 影响
@@ -22,10 +22,10 @@ skill技能引用 source 的浏览器端:把 `/` 触发的 `skill` sourc
#### KV Cache 影响
仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包package绝不改写较早的请求 token。
仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。
## 已知限制与暂缓事项
- **skill 加载确定**引用是协作线索不是保证模型可能忽略它。针对命中率不足情况的返工路径host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。
- **skill 加载具有非确定**引用是协作线索不是保证模型可能忽略它。针对命中率不足情况的返工路径host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。
- **首次击键可能与预热竞速**scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。
- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
README.md: 5d277a83c5f0bc4bcec5871e0618af28afb7b6d2
README.zh.md: 195aec6b76517fcf5cfc0933eb39b180f03a8628
README.zh.md: 08cc477edf657279c142a5e54a1471a118b9a021

View File

@@ -16,7 +16,7 @@ MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot列表类
#### KV Cache 影响
无;该包package既不组装也不发送提供方请求。
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-slots/README.md
README.md: ed6f052b3a47e08d693928b6763e32427b829467
README.zh.md: 17c3cbb28defe0c9bc66df417976984be3955b53
README.zh.md: e12e4bdad738c70657927b62d4a7bbd46d4b1519

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Slot 注册表纯核心、slot 终端设计SlotMap 声明合并、SlotCore 上唯一的 `register` 组合 API、四 share 组件 props 类型家族、store seat 类型家族,以及 renderer 安装 seam 契约。只使用 React 类型;该包package不依赖 React也不依赖 Cordis。
Slot 注册表纯核心、slot 终端设计SlotMap 声明合并、SlotCore 上唯一的 `register` 组合 API、四 share 组件 props 类型家族、store seat 类型家族,以及 renderer 安装 seam 契约。只使用 React 类型;该包不依赖 React也不依赖 Cordis。
一次 `register({ name, children?, store?, inject?, ...kind }, Component)` 调用会向已声明 slot 贡献一个组件,同时声明子 slot声明 = 渲染授权 = 运行时规范三者共用一张表、store seat 以及注册方的业务表层。组件会在调用点依据 `ComposedProps` 接受检查;该类型是四个 share 的交集,每个 share 都从各自的唯一真源派生:

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
README.md: cb210b219a8c66985eb4e1370468372eed9614b4
README.zh.md: 7b87fa1095c404eda96066189b1e4480cd6d4c3c
README.zh.md: 857e92d05a7ed2d0df9398acc9698db13b0c6eb2

View File

@@ -6,9 +6,9 @@ Web subagent 功能 owner向 `conversation.session.header.actions` 贡献可
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running``inactive` 活动状态和由日志支撑的可选 title尾随列则在上行显示提供方的持久化 token 用量总计在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRightArrowLeft 展开和折叠分支ArrowUpArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
one-shot child 始终选用只读编辑器,并将 transcript文本记录说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome Session 会通过 `subagent.prompt` 路由child 运行期间,输入操作仍为 Send因为每条后续消息都会进入 child 的 FIFO inbox且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。
one-shot child 始终选用只读编辑器,并将 transcript文本记录说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome会话会通过 `subagent.prompt` 路由child 运行期间,输入操作仍为 Send因为每条后续消息都会进入 child 的 FIFO inbox且已寻址会话绝不公开 Stop。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 规定。
普通侧边栏会省略带 subagent origin 的 Session 行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。
普通侧边栏会省略带 subagent origin 的会话行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。
`@` source 仍然刻意保持独立且惰性。候选是从 `ctx.sessions.list` 零 RPC 得到的运行中 childpick 会插入字面文本 `@label `codec 投影为 `@label`。它不参与命令裁决,也不会把 label 解析成继续执行地址。
@@ -18,7 +18,7 @@ one-shot child 始终选用只读编辑器,并将 transcript文本记录
#### 模型看到的内容
只有旧有 `@` 引用 source 会影响模型输入pick 的候选以字面文本 `@label` 进入普通用户消息,没有专用内容块或宿主侧解析。浏览目录、导航 child 与查看持久化 transcript 都不会添加提示词 section获准进入的继续交互内容会经宿主 subagent 适配器成为普通 FIFO 用户消息。
只有旧有 `@` 引用 source 会影响模型输入pick 的候选以字面文本 `@label` 进入普通用户消息,没有专用内容块或宿主侧解析。浏览目录、导航 child 与查看持久化 transcript 都不会添加提示词 section已接收的继续交互内容会经宿主 subagent 适配器成为普通 FIFO 用户消息。
#### Token 影响
@@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript文本记录
## 已知限制与暂缓事项
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开具备安全授权的取消按钮。
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开符合授权边界的取消按钮。
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: 5d0ea3bbbbfca2b8c0ee02ed07ca956fbd377e11
README.zh.md: 1bfff4c18ea2e834781e2c6cb76773595eeed5ad
README.zh.md: d88e4562296ccef8653f85ee74e2e6e856bd7d96

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图不会改变该区间。仅当记录表在流式更新前已经位于底部时更新才会保持贴底向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包package保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图不会改变该区间。仅当记录表在流式更新前已经位于底部时更新才会保持贴底向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
## 模型体验

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/web-react/README.md
README.md: 7cc80f22bd5527838288d11c819e81b7ec4d17c4
README.zh.md: 855417ff357b78b05fa54946c6cf84fafaa5180e
README.zh.md: 9019a9618d35b6fc92dcc2cc84f8f903fa1ee346

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
slot 终端设计的外壳侧 React 胶水createSlotRenderer外壳安装到运行时 SlotsService 的 SlotRenderer 实现、SessionProvider由框架接入的 render prop也作为标准 seat 注入到声明会话 scope 子 slot 的配置项、bindSnapshotSelector唯一的钩子构造器主机与引擎只传递裸 observable source每个钩子在此绑定并按 source 缓存、useInvoke。链式 slot outlet 在渲染时按链顺序运行已注册 selector只挂载被选中的配置项其 select 返回值以 `matched` 加入 props`renderSlotChain` 绑定与 `renderSlot` 一样按配置项缓存。快照 store 引擎与 defineStore 位于运行时store 已迁移);业务插件只依赖 ui-slots 类型,绝不依赖该包package
slot 终端设计的外壳侧 React 胶水createSlotRenderer外壳安装到运行时 SlotsService 的 SlotRenderer 实现、SessionProvider由框架接入的 render prop也作为标准 seat 注入到声明会话 scope 子 slot 的配置项、bindSnapshotSelector唯一的钩子构造器主机与引擎只传递裸 observable source每个钩子在此绑定并按 source 缓存、useInvoke。链式 slot outlet 在渲染时按链顺序运行已注册 selector只挂载被选中的配置项其 select 返回值以 `matched` 加入 props`renderSlotChain` 绑定与 `renderSlot` 一样按配置项缓存。快照 store 引擎与 defineStore 位于运行时store 已迁移);业务插件只依赖 ui-slots 类型,绝不依赖该包。
## 模型体验

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/code-runtime/README.md
README.md: dbe6b37ffa01d07c6902672a06ebf6f88548ff99
README.zh.md: f97bc091839bf7660a4a8c5ed506037d6b63eb3f
README.zh.md: a5acbad3cce19366ca9ca4729f5285905ab026eb

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
代码执行能力 seam参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于针对宿主提供的异步绑定执行一段模型编写的程序,并捕获程序打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)`tools: { mode: code }`,即 `run_code` 工具与生成的 TypeScript SDK设计记录在 [Code Mode Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 中。这些都是**产品**包package
代码执行能力 seam参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于针对宿主提供的异步绑定执行一段模型编写的程序,并捕获程序打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)`tools: { mode: code }`,即 `run_code` 工具与生成的 TypeScript SDK设计记录在 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 中。这些都是**产品**包。
| 包 | 职责 | ctx 键 |
|---|---|---|

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-worker/README.md
README.md: 83c9a398970831e88cb3ef5d71d3f175da97d1f1
README.zh.md: 3a4714fab55b85ffeb237d2a9fd0615ae4789bcf
README.zh.md: d071abe32371f652f420ebdc149145e483f88cfb

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
这是 [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam 的 worker 线程实现:`WorkerCodeRuntime` 会在每次运行中使用一个全新的 Node `worker_threads.Worker`,输入 TypeScript由宿主侧剥离类型通过消息端口桥接绑定输出 `{ value, logs, error? }`。**这是隔离措施,而非安全边界**:其信任立场有意与 bash 等价(参见 [Code Mode Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 的 Trust posture 章节),但提供 bash 没有的隔离:独立 isolate、空环境、堆上限与强制终止。
这是 [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam 的 worker 线程实现:`WorkerCodeRuntime` 会在每次运行中使用一个全新的 Node `worker_threads.Worker`,输入 TypeScript由宿主侧剥离类型通过消息端口桥接绑定输出 `{ value, logs, error? }`。**这是隔离措施,而非安全边界**:其信任立场有意与 bash 等价(参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 的 Trust posture 章节),但提供 bash 没有的隔离:独立 isolate、空环境、堆上限与强制终止。
## 配置
@@ -32,7 +32,7 @@
## 未构建与已构建的 worker 入口
源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`。其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包package尚未构建的 `lib/` 导出。worker 本地 JSON 快照器会与会话自有的规范边界执行一致性测试;消息端口两侧都会展平并重建已验证值,使应用嵌套永远不会进入 structured clone。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的虚拟文件系统VFSWorker hook 要求 CommonJS同一路径也可在普通 Node 下使用。`tests/built-lib.e2e.ts` 固定了 [docs/testing.md](../../../docs/testing.md) 要求的真实加载路径。
源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`。其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包尚未构建的 `lib/` 导出。worker 本地 JSON 快照器会与会话自有的规范边界执行一致性测试;消息端口两侧都会展平并重建已验证值,使应用嵌套永远不会进入 structured clone。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的虚拟文件系统VFSWorker hook 要求 CommonJS同一路径也可在普通 Node 下使用。`tests/built-lib.e2e.ts` 固定了 [docs/testing.md](../../../docs/testing.md) 要求的真实加载路径。
SDK 对外提供默认及具名导出的 `WorkerCodeRuntime` 类,以及 `Config`。运行所用的 `./worker` 子路径仅作为打包后的 spawn 入口存在wire 协议与启动辅助模块是源代码私有的实现细节。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime/README.md
README.md: c7a2d519e47d160f5ab123bfc887e7e9f24ec602
README.zh.md: 9103e7490e2fccb071cd8a234b224bc17514253e
README.zh.md: 22d0b120d7cea50b578a184b3e40d77707ebc489

View File

@@ -4,7 +4,7 @@
这是**代码执行 seam**:抽象的 `CodeRuntime` 服务(`ctx.codeRuntime`)只定义代码运行时做什么,即针对宿主提供的一组异步绑定运行一段模型编写的程序,并报告 `{ value, logs, error? }`,而不规定如何实现。
此包package承担该能力三个组成部分中的接口职责(以 bash 三包结构为模板,参见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):实现通过继承 `CodeRuntime` 并注册服务接入;消费方是工具注册表的 Code Mode它生成面向模型的 SDK并桥接工具分发。这两项职责均由 [Code Mode Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定,首个实现是 Node worker 线程后端。运行时不了解工具或会话:调用方只向它提供具名异步函数与程序字符串;所有与工具有关的内容都留在消费方。
此包承担该能力三个组成部分中的接口职责(以 bash 三包结构为模板,参见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):实现通过继承 `CodeRuntime` 并注册服务接入;消费方是工具注册表的 Code Mode它生成面向模型的 SDK并桥接工具分发。这两项职责均由 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) 规定,首个实现是 Node worker 线程后端。运行时不了解工具或会话:调用方只向它提供具名异步函数与程序字符串;所有与工具有关的内容都留在消费方。
## 服务 API`ctx.codeRuntime`

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/README.md
README.md: aa9fa6d9419de87a7df23a437f5ea8694d981b28
README.zh.md: e771eb4bc76358242737d92f92ec36324f55bf2b
README.zh.md: 7c8752d891880cdb3fd3015a5bd088c7b5e0f2b6

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
一个压缩compaction能力家族见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要生成后端、不依赖模型的工具结果剪枝配套组件,以及面向用户的命令适配器。这些全是**产品**包package
一个压缩compaction能力家族见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象接口、摘要生成后端、不依赖模型的工具结果剪枝配套组件,以及面向用户的命令适配器。这些全是**产品**包。
| 包 | 职责 | ctx key |
|---|---|---|
@@ -11,4 +11,4 @@
| `compact-tool-result-prune/` | 可选的不依赖模型的头/中/尾重写,在摘要压缩之前运行 | `ctx.toolResultPrune` |
| `command-compact/` | 面向用户的 `/compact` 命令,基于后端无关的 `compactNow()` seam | (注册到 `ctx.commands` |
接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`,命令位于 `compact/command-compact/`。与 bash seam 不同,该接口依赖 `dsh-session``dsh-llm`,因为它的操作以 `Session` 为对象,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM大语言模型家族服务基于模板或模型的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器、命令或自动调用方。
接口位于 `compact/compact/`,后端位于 `compact/compact-basic/`,确定性剪枝位于 `compact/compact-tool-result-prune/`,命令位于 `compact/command-compact/`。与 bash seam 不同,该接口依赖 `dsh-session``dsh-llm`,因为它的操作以 `Session` 为对象,输出则使用 `ContentBlock`。这项偏差记录在[压缩能力 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。token 测量仍是可复用的 LLM大语言模型家族服务基于模板或模型的压缩器可以替换 `compact-basic`,而无需更改计量器、剪枝器、命令或自动调用方。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/command-compact/README.md
README.md: 1445e76f8328a9ac1c5f9dd43094f1c1cd5d2ad4
README.zh.md: 0fb306afb3713e47fb17d2c914a4f691b63b77eb
README.zh.md: b7357c0efc83c8ccce709518ac2dd2f5e66bbace

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩compaction控制。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现它;随附 TUI 无需模型轮次即可执行该命令。[排队手动压缩 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。
通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩compaction控制。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现它;随附 TUI 无需模型轮次即可执行该命令。[排队手动压缩 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。
## 命令契约

View File

@@ -67,7 +67,7 @@ async function harness(): Promise<Harness> {
await ctx.plugin(CommandService)
const compact = new StubCompactService(ctx)
const plugin = await ctx.plugin(commandCompact)
const session = new Session(SessionId('command-compact'))
const session = Session.create(SessionId('command-compact'))
const agent = {
session,
status: 'idle',

View File

@@ -92,7 +92,7 @@ describe('command-compact real Loader composition', () => {
})
await context.loader.await()
const session = new Session(SessionId('loader-command-compact'))
const session = Session.create(SessionId('loader-command-compact'))
const agent = {
session,
status: 'idle',

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/compact-basic/README.md
README.md: 33a0a47346bed98ed0653d53d424ea7cd25c2a24
README.zh.md: 603a3104592e7e6acbf54c67ea9616ed9ab8c7cc
README.zh.md: fe4a0d7572bf800941a9050392923362ca3b415f

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
**基础压缩compaction后端**`BasicCompactService` 实现 `@deepseek-ai/dsh-compact` seam使用可复用的 `ctx.tokenMeter` 压力、token 预算保留与摘要。摘要是直接的一次性 `ctx.llm.stream()` 调用,它会回放会话前缀以复用提供方的 KV cache可在 `llm/stream` 处拦截)。
**基础压缩compaction后端**`BasicCompactService` 实现 `@deepseek-ai/dsh-compact` seam使用可复用的 `ctx.tokenMeter` 压力、token 预算保留与摘要。摘要是直接的一次性 `ctx.llm.stream()` 调用,它会回放会话前缀以复用提供方的 KV Cache可在 `llm/stream` 处拦截)。
这是压缩能力的实现层。seam 见 [接口包package](../compact/README.md),设计见 [能力 seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。
这是压缩能力的实现层。seam 见 [接口包](../compact/README.md),设计见 [能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。
## 拥有的职责

View File

@@ -103,7 +103,7 @@ function promptInput(text: string): SummarizationInput {
/** Closed two-message turns followed by one open turn for durable compaction events. */
function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
const session = new Session(SessionId(`conversation-${turns}`))
const session = Session.create(SessionId(`conversation-${turns}`))
for (let turn = 1; turn <= turns; turn += 1) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
@@ -140,7 +140,7 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
}
function toolConversation(): Session {
const session = new Session(SessionId('tools'))
const session = Session.create(SessionId('tools'))
for (let turn = 1; turn <= 3; turn += 1) {
const callId = CallId(`call-${turn}`)
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -189,7 +189,7 @@ function toolConversation(): Session {
/** One closed routed tool step followed by an open turn for rewrite events. */
function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
const session = new Session(SessionId(`oversized-tool-${chars}`))
const session = Session.create(SessionId(`oversized-tool-${chars}`))
const callId = CallId('oversized')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
if (withCompactablePrompt) {
@@ -485,7 +485,7 @@ describe('pressure measurement and retention', () => {
it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
const compact = service(compactConfig)
const session = new Session(SessionId('headerless'))
const session = Session.create(SessionId('headerless'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL))
.resolves.toBeNull()
@@ -567,7 +567,7 @@ describe('pressure measurement and retention', () => {
it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
const compact = service(compactConfig)
const session = new Session(SessionId('single-tool-pair'))
const session = Session.create(SessionId('single-tool-pair'))
const callId = CallId('single-call')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
@@ -658,7 +658,7 @@ describe('pressure measurement and retention', () => {
it('declines when envelope pressure is high but the surface has no compactable range', async () => {
const compact = service(compactConfig)
const empty = new Session(SessionId('empty'))
const empty = Session.create(SessionId('empty'))
empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
empty.append('request/header', {
header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
@@ -733,7 +733,7 @@ describe('pressure measurement and retention', () => {
it('declines when rounding a cut would consume the only tool pair', () => {
const ctx = createContext()
const session = new Session(SessionId('one-tool-pair'))
const session = Session.create(SessionId('one-tool-pair'))
const callId = CallId('only')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
@@ -874,7 +874,7 @@ describe('compaction region transaction', () => {
expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
const replay = new Session(SessionId('replay'), [...session.events])
const replay = Session.create(SessionId('replay'), [...session.events])
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
})
@@ -956,7 +956,7 @@ describe('compaction region transaction', () => {
it('rejects a session with no turn boundary at all', async () => {
const compact = service()
const session = new Session(SessionId('turnless'))
const session = Session.create(SessionId('turnless'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'orphan' }],
source: { kind: 'user' },
@@ -1076,7 +1076,7 @@ describe('compaction region transaction', () => {
it('lets a model-independent custom summarizer compact without a conversation model', async () => {
const compact = service()
const session = new Session(SessionId('model-less-region'))
const session = Session.create(SessionId('model-less-region'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'history '.repeat(100) }],
@@ -1313,13 +1313,13 @@ describe('default one-shot summarizer', () => {
await ctx.plugin(LlmService)
void new TokenMeterService(ctx)
const compact = new ExposedCompactService(ctx, { auto: false })
await expect(compact.runSummarize(promptInput('history'), agent(new Session(SessionId('model-less')))))
await expect(compact.runSummarize(promptInput('history'), agent(Session.create(SessionId('model-less')))))
.rejects.toThrow(/no provider\/model available for summarization/)
})
it('uses a complete AgentOptions target when no durable route exists', async () => {
const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
const session = new Session(SessionId('headerless-summary'))
const session = Session.create(SessionId('headerless-summary'))
await expect(compact.runSummarize(promptInput('history'), agent(session, MODEL))).resolves.toMatchObject({
provider: MODEL,
@@ -1335,7 +1335,7 @@ describe('default one-shot summarizer', () => {
])('rejects incomplete AgentOptions target %#', async (options) => {
const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }])
const owner = {
session: new Session(SessionId(`incomplete-${String(options.model)}`)),
session: Session.create(SessionId(`incomplete-${String(options.model)}`)),
options,
} as Agent
await expect(compact.runSummarize(promptInput('history'), owner))
@@ -1698,7 +1698,7 @@ describe('automatic listener and loader composition', () => {
it('delegates canonical overflow when no durable routed target exists', async () => {
const ctx = createContext()
void new TestCompactService(ctx)
const session = new Session(SessionId('headerless-overflow'))
const session = Session.create(SessionId('headerless-overflow'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },

View File

@@ -185,7 +185,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function overflowHistorySeed(): SessionEvent[] {
const session = new Session(SessionId('overflow-history-seed'))
const session = Session.create(SessionId('overflow-history-seed'))
for (let turn = 1; turn <= 2; turn += 1) {
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
session.append('turn/start', {

View File

@@ -166,7 +166,7 @@ function deferred(): { promise: Promise<undefined>; resolve: () => void } {
/** A closed-tail session with compactable exchanges and no live agent. */
function closedConversation(turns = 2, lastTurnNumber = turns): Session {
const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`))
const session = Session.create(SessionId(`closed-${turns}-${lastTurnNumber}`))
for (let index = 1; index <= turns; index += 1) {
const turn = index === turns ? lastTurnNumber : index
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -368,7 +368,7 @@ describe('compactNow through the real loop', () => {
describe('compactNow transaction and failure classification', () => {
it('returns null without writing a bracket for history that cannot be compacted', async () => {
const { compact } = detachedService()
const session = new Session(SessionId('empty'))
const session = Session.create(SessionId('empty'))
let released = 0
const agent = fakeAgent(session, () => () => { released += 1 })
@@ -410,7 +410,7 @@ describe('compactNow transaction and failure classification', () => {
const { compact } = detachedService()
const original = closedConversation(2)
original.append('compact/start', { turn: null })
const reloaded = new Session(SessionId('stale-orphan'), [...original.events])
const reloaded = Session.create(SessionId('stale-orphan'), [...original.events])
const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
const orphan = reloaded.events.find(event => event.type === 'compact/start')
const agent = fakeAgent(reloaded, () => () => undefined)
@@ -426,7 +426,7 @@ describe('compactNow transaction and failure classification', () => {
original.append('compact/start', { turn: null })
original.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events])
const agent = fakeAgent(reloaded, () => () => undefined)
await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
@@ -638,7 +638,7 @@ describe('compactNow transaction and failure classification', () => {
it('compacts a session with no durable turn boundary without creating one', async () => {
const { compact } = detachedService()
const session = new Session(SessionId('turnless'))
const session = Session.create(SessionId('turnless'))
for (const text of [PROMPT, 'recent tail']) {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
@@ -671,7 +671,7 @@ describe('compactNow transaction and failure classification', () => {
it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => {
const cases = [
{ name: 'busy', session: closedConversation(2), release: undefined },
{ name: 'empty', session: new Session(SessionId('pre-aborted-empty')), release: () => undefined },
{ name: 'empty', session: Session.create(SessionId('pre-aborted-empty')), release: () => undefined },
{ name: 'compactable', session: closedConversation(2, 9), release: () => undefined },
] as const

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/compact-tool-result-prune/README.md
README.md: edeba52b189b3cee5530faf7efc04043a326917f
README.zh.md: 1b42a9db5d3288c6610e431c57403858a238c07a
README.zh.md: abc19afa784ec1121430b57a9d90d22d12b89810

View File

@@ -4,7 +4,7 @@
可安全回放、不依赖模型的剪枝服务(`ctx.toolResultPrune`)。它会将超出预算的 `tool/result` 表层节点改写为长度受限的头部、固定省略标记和长度受限的尾部,同时在仅追加会话日志中保留完整原始事件。
这是 [`dsh-compact-basic`](../compact-basic/README.md) 的具体配套服务不是压缩compaction后端或面向模型的工具。Compact-basic 通过可选的 `ctx.get('toolResultPrune')` 读取它,因此这两个包package仍可各自独立组合。
这是 [`dsh-compact-basic`](../compact-basic/README.md) 的具体配套服务不是压缩compaction后端或面向模型的工具。Compact-basic 通过可选的 `ctx.get('toolResultPrune')` 读取它,因此这两个包仍可各自独立组合。
## 服务 API

View File

@@ -153,7 +153,7 @@ describe('ToolResultPruneService content transform', () => {
describe('ToolResultPruneService session transaction', () => {
it('prunes a stable snapshot, preserves all data, and records provenance', () => {
const session = new Session(SessionId('preserve'))
const session = Session.create(SessionId('preserve'))
const originalSeq = appendToolStep(session, 1, 'one', [{
type: 'text',
text: 'x'.repeat(100),
@@ -208,7 +208,7 @@ describe('ToolResultPruneService session transaction', () => {
})
it('prunes multiple results, skips short ones, and converges in one pass', () => {
const session = new Session(SessionId('multiple'))
const session = Session.create(SessionId('multiple'))
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
appendToolStep(session, 2, 'b', [{ type: 'text', text: 'short' }])
appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }])
@@ -227,14 +227,14 @@ describe('ToolResultPruneService session transaction', () => {
})
it('replays to the identical pruned model messages', () => {
const session = new Session(SessionId('replay'))
const session = Session.create(SessionId('replay'))
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
service().pruneSession(session)
const replay = new Session(session.id, [...session.events])
const replay = Session.create(session.id, [...session.events])
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration)
})

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/compact/README.md
README.md: cfb65f2a786dd58d38a7020a8caefeb3d7372f52
README.zh.md: e069bea9ef40d2e1ba7beead5b76324cfd56b839
README.zh.md: a804fd0eac4c509724eb7be79c57385311cb39a2

View File

@@ -4,7 +4,7 @@
**压缩compaction seam**:抽象 `CompactService``ctx.compact`)定义压缩做什么,即判定历史记录是否过大,并将较早范围摘要为单个表层节点,但不规定如何实现。
这个包package是压缩能力的接口层,因此各项职责均可独立演进,也可独立替换:
这个包是压缩能力的接口层,因此各项职责均可独立演进,也可独立替换:
| 包 | 职责 |
|---|---|
@@ -12,7 +12,7 @@
| `@deepseek-ai/dsh-compact-basic` | 后端:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 |
| `@deepseek-ai/dsh-command-compact` | 面向用户的 `/compact` 命令,基于 `ctx.compact.compactNow()` 实现 |
与 bash seam 不同,该接口依赖 `@deepseek-ai/dsh-session``@deepseek-ai/dsh-llm`。契约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「接口只依赖 cordis」指引的偏离是有意的并记录在 [压缩能力 seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。
与 bash seam 不同,该接口依赖 `@deepseek-ai/dsh-session``@deepseek-ai/dsh-llm`。契约的动词基于 `Session` 定义,其输出使用 `ContentBlock` 词汇,因此无法在不指名这些包的情况下表达。这项对「接口只依赖 cordis」指引的偏离是有意的并记录在 [压缩能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中。
## 服务 API`ctx.compact`

View File

@@ -105,7 +105,7 @@ describe('CompactService seam', () => {
it('exposes the abstract contract methods', async () => {
const ctx = new Context()
const svc = new StubCompactService(ctx)
const session = new Session(SessionId('s'))
const session = Session.create(SessionId('s'))
expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull()
const signal = new AbortController().signal
expect(await svc.compactNow({
@@ -118,7 +118,7 @@ describe('CompactService seam', () => {
it('compact/* events merge into SessionEventMap and are log-only', async () => {
const ctx = new Context()
const svc = new StubCompactService(ctx)
const session = new Session(SessionId('s'))
const session = Session.create(SessionId('s'))
const original = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
@@ -149,7 +149,7 @@ describe('CompactService seam', () => {
it('threads the cancellation signal through to the backend', async () => {
const ctx = new Context()
const svc = new StubCompactService(ctx)
const session = new Session(SessionId('s'))
const session = Session.create(SessionId('s'))
const controller = new AbortController()
const original = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }],

View File

@@ -56,7 +56,7 @@ describe('compaction invariants', () => {
it('clears an inherited open compaction trace at end-seed during replay', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = new Session(SessionId('stale-compaction-source'))
const source = Session.create(SessionId('stale-compaction-source'))
source.append('compact/start', { turn: null })
const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), {
seed: source.events,
@@ -76,7 +76,7 @@ describe('compaction invariants', () => {
it('allows repair turn boundaries after end-seed clears a seeded numbered orphan', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = new Session(SessionId('stale-numbered-compaction-source'))
const source = Session.create(SessionId('stale-numbered-compaction-source'))
startTurn(source)
source.append('compact/start', { turn: 1 })
const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), {
@@ -97,7 +97,7 @@ describe('compaction invariants', () => {
it('accepts inherited repair boundaries before the end-seed that clears a standalone orphan', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = new Session(SessionId('stale-repaired-compaction-source'))
const source = Session.create(SessionId('stale-repaired-compaction-source'))
source.append('compact/start', { turn: null })
startTurn(source)
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
@@ -123,7 +123,7 @@ describe('compaction invariants', () => {
it('rejects a closed standalone bracket that contains a turn before end-seed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const source = new Session(SessionId('closed-nested-compaction-source'))
const source = Session.create(SessionId('closed-nested-compaction-source'))
source.append('compact/start', { turn: null })
startTurn(source)
source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
@@ -152,7 +152,7 @@ describe('compaction invariants', () => {
it('adopts a bare session and ignores unrelated committed events', async () => {
const ctx = await setup()
const session = new Session(SessionId('bare-compaction-session'))
const session = Session.create(SessionId('bare-compaction-session'))
expect(() => {
ctx.emit('session/event', session, {
type: 'turn/start', seq: 0, time: 0,

View File

@@ -25,7 +25,7 @@ function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
}
function closedToolStep(): Session {
const session = new Session(SessionId('closed-tool-step'))
const session = Session.create(SessionId('closed-tool-step'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
@@ -64,7 +64,7 @@ describe('tool-pairing boundaries', () => {
expect(before(closed, 'tool/result')).toBe(false)
expect(after(closed, 'tool/result')).toBe(true)
const open = new Session(SessionId('open-tool-step'))
const open = Session.create(SessionId('open-tool-step'))
open.append('assistant/message', {
turn: 1,
step: 1,
@@ -81,7 +81,7 @@ describe('tool-pairing boundaries', () => {
})
it('requires every result from a multiple-call assistant message', () => {
const session = new Session(SessionId('multiple-calls'))
const session = Session.create(SessionId('multiple-calls'))
session.append('assistant/message', {
turn: 1,
step: 1,
@@ -119,7 +119,7 @@ describe('tool-pairing boundaries', () => {
})
it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => {
const midStep = new Session(SessionId('neutral-mid-step'))
const midStep = Session.create(SessionId('neutral-mid-step'))
midStep.append('assistant/message', {
turn: 1,
step: 1,
@@ -147,7 +147,7 @@ describe('tool-pairing boundaries', () => {
expect(before(midStep, 'user/message')).toBe(false)
expect(after(midStep, 'user/message')).toBe(false)
const free = new Session(SessionId('neutral-free'))
const free = Session.create(SessionId('neutral-free'))
free.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'idle injection' }],
source: { kind: 'user' },
@@ -187,7 +187,7 @@ describe('tool-pairing surface identity', () => {
})
it('rejects missing seqs before and after, including an empty surface', () => {
const session = new Session(SessionId('missing-membership'))
const session = Session.create(SessionId('missing-membership'))
const missing = 999
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
@@ -367,7 +367,7 @@ describe('tool-pairing cache refresh', () => {
describe('tool-pairing corrupt surfaces', () => {
it('throws for an orphan result during a rebuild', () => {
const session = new Session(SessionId('orphan-rebuild'))
const session = Session.create(SessionId('orphan-rebuild'))
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
@@ -380,7 +380,7 @@ describe('tool-pairing corrupt surfaces', () => {
})
it('retries an orphan result in an appended tail without committing partial cache state', () => {
const session = new Session(SessionId('orphan-tail'))
const session = Session.create(SessionId('orphan-tail'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' },
}), SURFACE)

View File

@@ -639,7 +639,7 @@ describe('session reference discovery and preparation', () => {
expect(JSON.stringify(before)).toContain('durable referenced fact')
expect(JSON.stringify(before)).toContain('use @source')
expect(JSON.stringify(before)).not.toContain('later source mutation')
expect(new Session(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before)
expect(Session.create(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before)
})
it('rejects direct invalid configuration before service publication', async () => {

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/time-context/README.md
README.md: 9fe818855439466b2a3e349cd54a2f408cf5ec10
README.zh.md: 1133715ebb3348d6e3dbbf8bbef169d6d8c56d8f
README.zh.md: fdc50bc89b3455351c67171301817f49570d89b7

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
可选的持久上下文,包含模型请求准备期间采样的带时区的当前时间与经过时长。`dsh-agent-spine-demo` 与随附示例不挂载该插件。决策记录:[持久 time-context Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md)。
可选的持久上下文,包含模型请求准备期间采样的带时区的当前时间与经过时长。`dsh-agent-spine-demo` 与随附示例不挂载该插件。决策记录:[持久 time-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md)。
## 配置
@@ -60,7 +60,7 @@ Elapsed since the preceding step context: <duration-or-unavailable>.
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
## 已知限制与暂缓事项

View File

@@ -44,7 +44,7 @@ function reading(
}
function preparing(turn: number, step: number): Session {
const session = new Session(SessionId(`time-invariant-${turn}-${step}`))
const session = Session.create(SessionId(`time-invariant-${turn}-${step}`))
for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } })
@@ -136,7 +136,7 @@ describe('time-context invariants', () => {
started.append('step/start', { turn: 1, step: 1 })
expect(() => { ctx.emit('session/event', started, event(reading())) }).toThrow(/must precede step\/start/)
expect(() => {
ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading()))
ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading()))
}).toThrow(/inside an open turn/)
})

View File

@@ -141,7 +141,7 @@ function requestText(request: GenerateOptions): string {
describe('durable step context', () => {
it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => {
const { ctx } = await mount({ timeZone: 'Asia/Shanghai' })
const session = new Session(SessionId('first'))
const session = Session.create(SessionId('first'))
openMessageTurn(session, 1)
vi.setSystemTime(BASE + 90_061_000)
@@ -160,7 +160,7 @@ describe('durable step context', () => {
it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('unavailable'))
const session = Session.create(SessionId('unavailable'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await fire(ctx, sessionAgent(session), 1, 1)
@@ -175,7 +175,7 @@ describe('durable step context', () => {
['zero interval', { refreshIntervalMs: 0 }],
] as const)('uses the preceding durable step-context timestamp after step one with %s', async (_label, config) => {
const { ctx } = await mount(config)
const session = new Session(SessionId('later-step'))
const session = Session.create(SessionId('later-step'))
const agent = sessionAgent(session)
openMessageTurn(session, 3)
await fire(ctx, agent, 3, 1)
@@ -191,7 +191,7 @@ describe('durable step context', () => {
it('reports an unavailable later-step baseline at the matching turn boundary', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('later-step-boundary'))
const session = Session.create(SessionId('later-step-boundary'))
openMessageTurn(session, 4)
await fire(ctx, sessionAgent(session), 4, 2)
@@ -203,7 +203,7 @@ describe('durable step context', () => {
it('reports an unavailable later-step baseline when event lookup is exhausted', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('later-step-exhausted'))
const session = Session.create(SessionId('later-step-exhausted'))
await fire(ctx, sessionAgent(session), 1, 2)
@@ -214,7 +214,7 @@ describe('durable step context', () => {
it('injects after backward wall-clock movement and clamps elapsed time to zero', async () => {
const { ctx } = await mount({ refreshIntervalMs: 60_000 })
const session = new Session(SessionId('backward'))
const session = Session.create(SessionId('backward'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
await fire(ctx, agent, 1, 1)
@@ -228,7 +228,7 @@ describe('durable step context', () => {
it('uses a shadowed durable injection after resume and injects at the exact threshold', async () => {
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
const original = new Session(SessionId('seed-source'))
const original = Session.create(SessionId('seed-source'))
openMessageTurn(original, 1)
await fire(ctx, sessionAgent(original), 1, 1)
const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user')
@@ -244,7 +244,7 @@ describe('durable step context', () => {
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing')
const resumed = new Session(SessionId('resumed'), [...original.events])
const resumed = Session.create(SessionId('resumed'), [...original.events])
const resumedAgent = sessionAgent(resumed)
vi.setSystemTime(BASE + 999)
openMessageTurn(resumed, 2)
@@ -266,7 +266,7 @@ describe('durable step context', () => {
it('applies a positive interval across turns without sharing state between sessions', async () => {
const { ctx } = await mount({ refreshIntervalMs: 1_000 })
const first = new Session(SessionId('interval-first'))
const first = Session.create(SessionId('interval-first'))
const firstAgent = sessionAgent(first, 'first-agent')
openMessageTurn(first, 1)
await fire(ctx, firstAgent, 1, 1)
@@ -277,7 +277,7 @@ describe('durable step context', () => {
const beforeSkip = first.events.length
await fire(ctx, firstAgent, 2, 1)
const independent = new Session(SessionId('interval-independent'))
const independent = Session.create(SessionId('interval-independent'))
openMessageTurn(independent, 1)
await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1)
@@ -288,7 +288,7 @@ describe('durable step context', () => {
it('runs before ordinary pre-step listeners and skips an already-aborted step', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('ordering'))
const session = Session.create(SessionId('ordering'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
let ordinarySawContext = false
@@ -311,7 +311,7 @@ describe('configuration and lifecycle', () => {
process.env['TZ'] = 'Asia/Shanghai'
const { ctx } = await mount()
process.env['TZ'] = 'America/New_York'
const session = new Session(SessionId('system-zone'))
const session = Session.create(SessionId('system-zone'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
@@ -345,7 +345,7 @@ describe('configuration and lifecycle', () => {
it('removes its listener when the plugin fiber disposes', async () => {
const { ctx, fiber } = await mount()
const session = new Session(SessionId('dispose'))
const session = Session.create(SessionId('dispose'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
await fire(ctx, agent, 1, 1)
@@ -445,7 +445,7 @@ describe('real Loader export path', () => {
await ctx.plugin(AgentRegistry)
const plugin = loader.unwrapExports(timeContext) as Parameters<Context['plugin']>[0]
await ctx.plugin(plugin)
const session = new Session(SessionId('loader'))
const session = Session.create(SessionId('loader'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)[0]).toContain('Time sampled while preparing turn 1, step 1:')

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md
README.md: 053206797398aa952522298e82992a7320daf74c
README.zh.md: 439f3e7712b0803b07a9a7e9dd10d9e863876154
README.zh.md: 444d144da362b196e0d1025739a04f5f6c18aa92

View File

@@ -55,9 +55,9 @@ window active=<0|1>, pane active=<0|1>, layout <window-layout>
每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。
#### KV 缓存影响
#### KV Cache 影响
只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV 缓存条目失效。
只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV Cache 条目失效。
## 已知限制与后续工作

View File

@@ -150,7 +150,7 @@ afterEach(() => {
describe('tmux-context injection', () => {
it('injects the tmux location on the first step of a turn', async () => {
const { ctx } = await mount({}, true)
const session = new Session(SessionId('first'))
const session = Session.create(SessionId('first'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
@@ -169,7 +169,7 @@ describe('tmux-context injection', () => {
it('queries the pane this process runs in and matches its controlling tty', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('command'))
const session = Session.create(SessionId('command'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
@@ -187,7 +187,7 @@ describe('tmux-context injection', () => {
it('does not run on later steps of a turn', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('later-step'))
const session = Session.create(SessionId('later-step'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 2)
@@ -198,7 +198,7 @@ describe('tmux-context injection', () => {
it('re-injects a new turn only when tmux state changed', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('change'))
const session = Session.create(SessionId('change'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
@@ -226,7 +226,7 @@ describe('tmux-context injection', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true)
const session = new Session(SessionId('interval'))
const session = Session.create(SessionId('interval'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
@@ -253,7 +253,7 @@ describe('tmux-context injection', () => {
describe('tmux-context prior-reading resilience', () => {
it('treats a prior non-text plugin reading as absent and injects afresh', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('prior-non-text'))
const session = Session.create(SessionId('prior-non-text'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
session.append('user/message', createUserMessage({
@@ -269,7 +269,7 @@ describe('tmux-context prior-reading resilience', () => {
it('treats a prior single-line plugin reading (no newline) as empty state', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('prior-single-line'))
const session = Session.create(SessionId('prior-single-line'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
session.append('user/message', createUserMessage({
@@ -288,7 +288,7 @@ describe('tmux-context prior-reading resilience', () => {
describe('tmux-context no-op paths', () => {
it('is a no-op when no bash executor is mounted', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('no-bash'))
const session = Session.create(SessionId('no-bash'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
@@ -299,7 +299,7 @@ describe('tmux-context no-op paths', () => {
it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => {
const { ctx, bash } = await mount({}, true)
bash.result = runResult('', { exitCode: 1 })
const session = new Session(SessionId('outside-tmux'))
const session = Session.create(SessionId('outside-tmux'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
@@ -310,7 +310,7 @@ describe('tmux-context no-op paths', () => {
it('is a no-op when the reading has the wrong field count', async () => {
const { ctx, bash } = await mount({}, true)
bash.result = runResult('0\\t1\\tnode\n')
const session = new Session(SessionId('malformed'))
const session = Session.create(SessionId('malformed'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
@@ -321,7 +321,7 @@ describe('tmux-context no-op paths', () => {
it('is a no-op when the pane id is empty', async () => {
const { ctx, bash } = await mount({}, true)
bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`)
const session = new Session(SessionId('empty-pane'))
const session = Session.create(SessionId('empty-pane'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
@@ -333,7 +333,7 @@ describe('tmux-context no-op paths', () => {
const { ctx, bash } = await mount({}, true)
bash.runError = new Error('bash executor unavailable')
const warn = vi.spyOn(ctx.logger, 'warn')
const session = new Session(SessionId('run-rejected'))
const session = Session.create(SessionId('run-rejected'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
@@ -346,7 +346,7 @@ describe('tmux-context no-op paths', () => {
const { ctx, bash } = await mount({}, true)
bash.resolveError = new Error('command denied by policy')
const warn = vi.spyOn(ctx.logger, 'warn')
const session = new Session(SessionId('resolve-rejected'))
const session = Session.create(SessionId('resolve-rejected'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
@@ -360,7 +360,7 @@ describe('tmux-context no-op paths', () => {
// Non-Error throw: the executor seam is typed, but a bad impl can reject with anything.
bash.runError = 'spawn refused' as unknown as Error
const warn = vi.spyOn(ctx.logger, 'warn')
const session = new Session(SessionId('non-error-rejection'))
const session = Session.create(SessionId('non-error-rejection'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
@@ -371,7 +371,7 @@ describe('tmux-context no-op paths', () => {
it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => {
const { ctx } = await mount({}, true)
const session = new Session(SessionId('ordering'))
const session = Session.create(SessionId('ordering'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
let ordinarySawContext = false

View File

@@ -170,7 +170,7 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace
function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
const id = SessionId('s1')
const session = new Session(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd })
const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd })
return {
ctx: new Context(),
id: SessionId('a1'),

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/cordis/README.md
README.md: 485a6ce7858a77507c07b76138127faa411b354b
README.zh.md: 38bfcd9fcb50f608e83bafa34def5561a847c066
README.zh.md: d6fb91d8426f4a4d9dae0f948001f4fba4014757

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
这些 Plugin Harness 自有格式集成到 Cordis 运行时:包括自指的模型工具集,以及受限的 repository Plugin 运行时。
这些插件harness 自有格式集成到 Cordis 运行时:包括自指的模型工具集,以及受限的 repository 插件运行时。
| 包package | 角色 | ctx 键 |
| 包 | 角色 | ctx 键 |
|---|---|---|
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect``cordis_mount``cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的临时插件 | 注册到 `ctx.tools` |
| [`repository-plugin/`](repository-plugin/README.md) | 通过 DSH 自有子 Plugin 准备并挂载静态 repository skills 与通用 `.mcp.json` server | 注册一个 Loader builtin |
| [`repository-plugin/`](repository-plugin/README.md) | 通过 DSH 自有子插件准备并挂载静态 repository skill(技能)与通用 `.mcp.json` server | 注册一个 loader builtin |

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/cordis/repository-plugin/README.md
README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7
README.zh.md: 2d9544166eafbb1066b65031969925890f2b9797
README.zh.md: d3046db8155a908dd4afd8f50a5ab4774085a40f

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
这是 DeepSeek Harness 的受限 repository Plugin 格式。仓库作者在 `.dsh-plugin/package.json` 中声明静态 skill 根和可选的通用 `.mcp.json`prepare helper 会复制这些资源并生成固定、无 import 的 Cordis 包装模块。运行时包装模块只能委托给这个由 DSH 自有的包,再由它组合 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md)。设计依据见[静态 repository Plugin 格式 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。
这是 DeepSeek Harness 的受限 repository 插件格式。仓库作者在 `.dsh-plugin/package.json` 中声明静态 skill(技能)根和可选的通用 `.mcp.json`prepare helper 会复制这些资源并生成固定、无 import 的 Cordis 包装模块。运行时包装模块只能委托给这个由 DSH 自有的包,再由它组合 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md)。设计依据见[静态 repository 插件格式 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。
## 创作格式
在仓库的 `.dsh-plugin` 目录中放置一个普通 package
在仓库的 `.dsh-plugin` 目录中放置一个普通
```json
{
@@ -26,7 +26,7 @@
}
```
`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package
`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin`
## 独立应用配置
@@ -41,7 +41,7 @@
- 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin'
```
每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份tag 和 branch 仍可作为显式配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`
每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份tag 和 branch 仍可作为精确配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`
TUI 和 Web 通过 Cordis HMR热模块替换监视 `config.yaml`。有效的源列表变更会安装并替换整套仓库插件 generation拉取、准备、导入或插件应用失败时最后一个可用树保持运行并广播 `hmr/config-update-failed(filename, error)`。无头运行只在启动时使用该文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。
@@ -49,25 +49,25 @@ TUI 和 Web 通过 Cordis HMR热模块替换监视 `config.yaml`。有效
`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest元数据清单以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript运行时也不会导入仓库的其他入口。
外层 package manager 仍会运行已配置仓库 package 的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行 package-manager source 安装的仓库,它并不是安全边界。
外层包管理器仍会运行已配置仓库包的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行包管理器源安装的仓库,它并不是安全边界。
## 运行时组合
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files``.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files``.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 generation 是不可变的。包装模块 dispose(资源释放)时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。
## 通用 MCP 格式
`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"``command``args``env`HTTP 条目只接受 `type: "http"``url``headers`。字符串值在 Plugin 加载时支持严格的 `${NAME}` 进程环境变量展开缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transportstdio 条目以已准备的 package 目录作为 `cwd`
`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"``command``args``env`HTTP 条目只接受 `type: "http"``url``headers`。字符串值在插件加载时支持严格的 `${NAME}` 进程环境变量展开缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transportstdio 条目以已准备的目录作为 `cwd`
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期;网络或子进程连接失败沿用该 client 既有的“记录错误且不注册工具”行为。
## 导出形状
Namespace Plugin:具名导出 `name``inject``apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。
Namespace 插件:具名导出 `name``inject``apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。
## 模型体验
### Repository skills
### Repository skill
#### 模型看到什么
@@ -75,11 +75,11 @@ Namespace Plugin具名导出 `name``inject``apply`、准备阶段常量
#### Token 影响
有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基指引加入保留的工具历史。
有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基指引加入保留的工具历史。
#### KV Cache 影响
稳定的已准备 Plugin 集合保持前缀稳定。添加、移除或替换 repository Plugin 可能使消费方追加替换目录,并影响后续请求前缀。
稳定的已准备插件集合保持前缀稳定。添加、移除或替换 repository 插件可能使消费方追加替换目录,并影响后续请求前缀。
### Repository MCP 工具
@@ -89,14 +89,14 @@ Namespace Plugin具名导出 `name``inject``apply`、准备阶段常量
#### Token 影响
取决于连接成功和远端工具列表schema 会在对应工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩。
取决于连接成功和远端工具列表schema 会在当前工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩compaction
#### KV Cache 影响
稳定的已连接工具列表保持前缀稳定。Plugin 生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。
稳定的已连接工具列表保持前缀稳定。插件生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。
## 已知限制与延后工作
## 已知限制与暂缓事项
- **仅支持 skills 与 MCP**commands、hooks、agents、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。
- **仅支持 skill 与 MCP**commands、钩子、agent(智能体)、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。
- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。
- **生成资源是不可变运行时输入**repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/cordis/tool-cordis/README.md
README.md: eda135d93e2912bbb4e111af40d176409b383b5b
README.zh.md: 6eef10086142d56dd809e5114b4e0e712f726ecc
README.zh.md: 773d4100f1be6c54f491838b65205b85ec61cdbc

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
自引用 Cordis 工具集:三个面向模型的工具,操作当前 DSH 进程中的实时运行时。沙箱语义、临时插件生命周期与组合、生成的 API 目录及既定决策详见[工具集 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
自引用 Cordis 工具集:三个面向模型的工具,操作当前 DSH 进程中的实时运行时。沙箱语义、临时插件生命周期与组合、生成的 API 目录及既定决策详见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
## 功能
@@ -14,7 +14,7 @@
规范成功结果分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生渲染会说明临时插件正在运行还是等待中,并说明它可用至被卸载或 DSH 重启;卸载结果确认它已移除。
临时插件只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent智能体通过常规开发流程实现普通的本地、项目或仓库插件。
临时插件只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent智能体通过常规开发流程实现普通的本地、项目或仓库插件。
## 信任立场
@@ -66,7 +66,7 @@ Namespace 插件:命名导出 `name``inject``Config``apply`,无默
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
### cordis_mount 后的后续请求

View File

@@ -1795,7 +1795,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ConfinedArgv',
declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureSignatures: readonly string[];\n}',
declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureRules: readonly RunnerFailureRule[];\n}',
},
{
name: 'ConfinedSandboxMode',
@@ -2349,6 +2349,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ResumeAgentOptions',
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}',
},
{
name: 'RunnerFailureRule',
declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}',
},
{
name: 'SandboxEnforcement',
declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';',
@@ -2407,7 +2411,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Session',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
},
{
name: 'SessionAvailability',

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/README.md
README.md: 63ca0f7711c2c9deb193d5a9a574ff909a04602e
README.zh.md: a217868f6c25ddce690ca32fc09b1d68b8ab48d2
README.zh.md: d4c1a85ef7dd33be5419245464d83da24756443a

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
会话日志、系统提示词组装、工具注册表、agent智能体词汇以及构成 harness 默认控制主干的具体循环。这些是 **产品**package,插件和消费方以其稳定接口为基础构建。
会话日志、系统提示词组装、工具注册表、agent智能体词汇以及构成 harness 默认控制主干的具体循环。这些是 **产品** 包,插件和消费方以其稳定接口为基础构建。
| 包 | 角色 | ctx 键 |
|---|---|---|

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
README.md: 2ce85071c4b7408adb4ee05291c499ec642be114
README.zh.md: bc78c02fc046f3bb5820f89bae5a90b26b5a8ced
README.zh.md: d0ef7f3d9bf3a2fe297171c4015f8500210e0155

View File

@@ -2,15 +2,15 @@
[English](README.md) | 中文
唯一的体 agent智能体插件与循环驱动器。其包package内部实现满足 `Agent` 接口,并驱动会话/轮次/步骤生命周期。
唯一的体 agent智能体插件与循环驱动器。其包内部实现满足 `Agent` 接口,并驱动会话/轮次/步骤生命周期。
这是 harness 中唯一包含体循环逻辑的包。其他所有内容要么是抽象服务,要么是针对扩展 seam 的插件:新行为应放入插件,而不是这里。
这是 harness 中唯一包含体循环逻辑的包。其他所有内容要么是抽象服务,要么是针对扩展 seam 的插件:新行为应放入插件,而不是这里。
## 服务:`AgentLoop`ctx 键:`agentLoop`
### 公开 API
创建与恢复属于同一个受回滚保护的事务:构造私有会话、体 agent 和带作用域的上下文;等待可选 setup同步调用其可选的发布提交进入两个注册表依次宣告 `session/created``agent/created`;发出 `agent/session-start`此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。其可选提交会在所有 setup 的 await 均结算后、进入注册表之前立即重新校验可变的配置状态;若其抛出异常,则回滚私有事务且不发布任何一个 id。普通的类型化身份与选项输入遵循只读契约以借用方式传入seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载setup发布并在返回的 handle 可见前分离。
创建与恢复属于同一个受回滚保护的事务:构造私有会话、体 agent 和带作用域的上下文;等待可选 setup同步调用其可选的发布提交进入两个注册表依次宣告 `session/created``agent/created`;发出 `agent/session-start`此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。其可选提交会在所有 setup 的 await 均结算后、进入注册表之前立即重新校验可变的配置状态;若其抛出异常,则回滚私有事务且不发布任何一个 id。普通的类型化身份与选项输入遵循只读契约以借用方式传入seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载setup发布并在返回的 handle 可见前分离。
调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)``resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose资源释放或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。
@@ -51,25 +51,25 @@ interface Config {
通过配置创建的 agent 会自动启动。模型调用同时需要 `provider``model``agent/request` 可以在分发前补齐缺失的这一对值。可选的正数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10``cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona编程式 setup 可以按 agent 遮蔽它。该插件为每个 agent 提供 `provider``model``cwd` 提示词变量harness 身份与部署 persona 属于 `dsh-system-prompt`
### 包内部体驱动器
### 包内部体驱动器
`ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
`ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。`reserveTurnAdmission()` 可以为独立持久操作同步保留该空闲边界:已获接纳的唤醒工作拥有优先权,之后发送的项保留普通队列身份与 FIFO 位置,释放会重新启用同一驱动器路径,`whenIdle()` 会等待预留结束,但 teardown 不会等待它。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()``inject()` 会暂存到同一个 outbox接纳获准后会开启轮次记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering中途引导及与其一同暂存的上下文则继续待处理以供重试或之后获准的提示词使用。窗口之外steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。
`steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`捕获派生历史并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose(资源释放),或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected``cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交包括被中断批次中已最终确认的上下文steering 则保持待准入,直到请求接纳它。
`steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`派生历史创建快照并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected``cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交包括被中断批次中已最终确认的上下文steering 则保持待准入,直到请求接纳它。
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard严格 steering 会把不可变消息作为新的 steering 单次入队项转移到开放的 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不做任何变更;待处理 steering 和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`
### 循环生命周期(`agent.ts`
驱动器在其整个生命周期内拥有一个 agent并在 `ctx.agents.withInitiator(agent, ...)` 内运行。包私有的编排入口点会恢复确切的 Agent一次性派生 `agent.session`,并让操作局部的辅助函数捕获它,而不是通过浅层接口继续传递体驱动器或每次操作的 `Session`。如果显式 `Session` 正是辅助函数的实际接口,该辅助函数会保留它;创建、持久化加载、未发布 setup、服务、worker、进程、持久化和 wire 协议则继续保留各自的显式身份。[agent 服务](../agent/README.md#initiating-agent-scope)规定传播、teardown 和分离工作规则。
驱动器在其整个生命周期内拥有一个 agent并在 `ctx.agents.withInitiator(agent, ...)` 内运行。包私有的编排入口点会恢复确切的 Agent一次性派生 `agent.session`,并让操作局部的辅助函数捕获它,而不是通过浅层接口继续传递体驱动器或每次操作的 `Session`。如果显式 `Session` 正是辅助函数的实际接口,该辅助函数会保留它;创建、持久化加载、未发布 setup、服务、worker、进程、持久化和 wire 协议则继续保留各自的显式身份。[agent 服务](../agent/README.md#initiating-agent-scope)规定传播、teardown 和分离工作规则。
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的分片溯源(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。
`agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`在活跃轮次信号的控制下校验由适配器持有的字段并填入配置的推理reasoning强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR热模块替换不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall瀑布式事件循环会从提议中移除这些带标记字段使当前精确路由重新填入自身默认值未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会应用同一来源规则。
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`循环用其错误关闭失败轮次并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end``user``parent` 记录 `aborted`dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`循环用其错误关闭失败轮次并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end``user``parent` 记录 `aborted`dispose 则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
在步骤内独占调用形成屏障并行安全调用使用有界滚动池并在启动前重新分类。只有分发主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。内部调度器故障会停止新的分发,等待已启动的分发,然后在不虚构工具结果的情况下到达轮次错误边界。
@@ -126,7 +126,7 @@ interface Config {
#### KV Cache 影响
仅追加;每个合成结果都位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
仅追加;每个合成结果都位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
## 已知限制与暂缓事项

View File

@@ -14,6 +14,7 @@ import type {
AgentFactory,
AgentHandle,
AgentOptions,
AgentSetup,
CreateAgentOptions,
ResumeAgentOptions,
SessionStartSource,
@@ -555,23 +556,40 @@ export class AgentLoop extends Service implements AgentFactory {
...options.seed === undefined ? {} : { seed: options.seed },
...options.meta === undefined ? {} : { meta: options.meta },
})
const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal)
const published = (async () => {
try {
const setupCommit = await raceAbort(
options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId,
)
setupCommit?.commit()
return prepared.publish('startup')
} catch (error: unknown) {
await prepared.dispose()
throw error
}
})()
const published = this.setupAndPublish(
ownerCtx,
options.sessionId,
session,
options.agentOptions ?? {},
options.setup,
options.signal,
'startup',
)
this.ownership.trackWrapper(published)
return published
}
/** Prepare one Agent around an acquired Session, run setup, and publish it. */
private async setupAndPublish(
ownerCtx: Context,
id: SessionId,
session: Session,
agentOptions: AgentOptions,
setup: AgentSetup | undefined,
signal: AbortSignal | undefined,
source: SessionStartSource,
): Promise<AgentHandle> {
const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal)
try {
const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id)
setupCommit?.commit()
return prepared.publish(source)
} catch (error: unknown) {
await prepared.dispose()
throw error
}
}
/**
* Resume an owned agent from the configured persistence service.
* @param ownerCtx - caller context that owns load, setup, and the live lifecycle.

View File

@@ -44,7 +44,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
if (header === undefined) {
return fail('a loop-built request with no request/header event in its session log')
}
const rebuilt = new Session(
const rebuilt = Session.create(
SessionId(`${String(session.id)}-invariant-rebuild`),
structuredClone(events.slice(0, boundary)),
)

View File

@@ -203,7 +203,7 @@ describe('addressable inbox operations', () => {
it('keeps a queued occurrence when the next-step window is closed', () => {
const ctx = new Context()
const session = new Session(SessionId('queue-to-steer-closed'))
const session = Session.create(SessionId('queue-to-steer-closed'))
const agent = new ReactLoopAgent(ctx, session.id, {}, session)
const enqueued: InboxItem[] = []
const discarded: InboxItem[] = []
@@ -772,7 +772,7 @@ describe('turn numbering continues across seeded sessions', () => {
describe('discriminated SessionEvent narrows without casts', () => {
it('narrows event.data from event.type', () => {
const session = new Session(SessionId('s'))
const session = Session.create(SessionId('s'))
const appended: SessionEvent = session.append('tool/call', {
turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}',
})

View File

@@ -597,7 +597,7 @@ describe('request stability across the loop', () => {
const stepStart = stepStarts[index]!
// Messages: the derivation over the log prefix strictly before this
// step's step/start — rebuilt here through a completely fresh Session.
const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq)))
const rebuilt = Session.create(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq)))
expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages())
// Header: the latest request/header snapshot up to this step's dispatch

View File

@@ -625,7 +625,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
expect(a2.session.events.length).toBe(events1.length + 1)
expect(a2.session.firstLiveSeq).toBe(events1.length)
expect(a2.session.events.at(-1)?.type).toBe('session/end-seed')
const replay = new Session(SessionId('replay'), events1)
const replay = Session.create(SessionId('replay'), events1)
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
// …and a new turn continues numbering (turn 2) with contiguous seqs.

Some files were not shown because too many files have changed in this diff Show More