mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge commit 'refs/codex-unblock/2026-07-23/base-511' into HEAD
This commit is contained in:
@@ -18,7 +18,7 @@ Persistence is an abstract **capability seam** ([capability seams](2026-06-13-ca
|
||||
Key choices recorded here because they are durable, contested, and surprising:
|
||||
|
||||
- **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
|
||||
- **Append-only; a crashed turn is closed, never truncated.** Events through a flushed `turn/end` are never rewritten, and the loop flushes only at turn end. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
|
||||
- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
|
||||
- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows.
|
||||
- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
|
||||
- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.
|
||||
|
||||
@@ -8,13 +8,17 @@ The ACP bridge gives every session its own workspace: `session/new` records the
|
||||
|
||||
Filesystem resolution used one plugin-load cwd while bash used the session project directory. Relative paths therefore disagreed whenever the editor project differed from the server launch directory; snapshots hid the bug by making those paths identical.
|
||||
|
||||
A valid absolute cwd can itself have two apparent parents: when it contains `symlink/..`, filesystem lookup follows the symlink before applying `..`, while `path.resolve()` erases both components lexically. Resolving sandbox policy lexically while launching bash from the raw cwd granted the unrelated lexical parent, denied writes in the real workspace, and let filesystem tools resolve relative paths into the wrong directory.
|
||||
|
||||
An ordinary symlink cwd exposes the same distinction when the requested relative path contains `..`: a process traverses from the symlink's physical target, while `path.resolve(cwd, path)` traverses from its lexical spelling. Reads would therefore select a different file than bash or a sandboxed mutation for the same model-supplied path.
|
||||
|
||||
## Decision
|
||||
|
||||
Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent.
|
||||
Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. When either the cwd or the requested path contains a parent segment, resolve the cwd to its native filesystem identity before any lexical join; ordinary cwd spellings stay stable for display when no traversal makes their identity observable. Reuse the resolved sandbox-policy root for mutations and sandboxed bash calls so one call has one workspace identity. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent.
|
||||
|
||||
- `FileSystem.resolve` accepts `resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. `opts.signal` cancels resolution when the backend performs I/O. The options object keeps both caller-owned resolution controls together without positional growth.
|
||||
- `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace).
|
||||
- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. A non-agent / headerless caller yields `undefined`, so the backend applies its default.
|
||||
- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec, requestedPath)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. The helper uses native realpath semantics when a parent segment in either value could cross a symlink while retaining ordinary spellings otherwise; a sandboxed mutation reuses the complete policy's `workspaceRoot`; a non-agent / headerless caller yields `undefined`, so the backend applies its default.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -27,6 +31,7 @@ The default lives in ONE place — the provider's `config.cwd`. `sessionCwd` ret
|
||||
## Consequences
|
||||
|
||||
- In the ACP demo the fs tools and bash now agree on each session's workspace; an editor can open any project folder and both tool families act on it.
|
||||
- A session cwd containing `symlink/..`, or an ordinary symlink cwd paired with a parent-traversing relative path, resolves from the same physical workspace for bash, filesystem tools, and the sandbox grant; the lexical parent receives no grant.
|
||||
- No change to `FsTarget` identity: `targetKey` is still the realpath of the resolved absolute path, so observed-state keying and symlink identity are unaffected — a correct per-session cwd produces the same key bash targets.
|
||||
- Backward compatible: every existing `resolve(path)` call (all in tests) keeps working; the new argument is optional.
|
||||
- The single-session stdio demo is unaffected: it supplies no session cwd (its agent's session has no `cwd`), so resolution falls back to `config.cwd = process.cwd()`, which is the workspace.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-19-package-invariant-runtime-contracts.md: 7d1fb1ad5a2e7563bdddffde1f49368b9f0c13f7
|
||||
2026-07-19-package-invariant-runtime-contracts.zh.md: 669eb02221aea4b0654497bb81327d725648dabe
|
||||
2026-07-19-package-invariant-runtime-contracts.md: 40d152b2320ac65f9ea7d8732b1a667236d2780a
|
||||
2026-07-19-package-invariant-runtime-contracts.zh.md: bd2f440d5dce15b352e7bcea0d1243400d290f11
|
||||
|
||||
@@ -59,7 +59,7 @@ Session-backed companions validate existing durable events when they load, using
|
||||
|
||||
`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers, default exports, and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls.
|
||||
|
||||
Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate stages each package's exact `npm pack` file inventory, imports its compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so an unpublished shared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation.
|
||||
Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. After the structural gate validates each publication map, an artifact gate stages its manifest-declared `lib/` files, imports the compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so a companion that imports an undeclared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ Status: implemented
|
||||
|
||||
`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。
|
||||
|
||||
Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁会按每个包的精确 `npm pack` 文件清单暂存文件,在 plain Node 下导入该包已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,未发布的共享运行时分片会在正式发布前导致门禁失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。
|
||||
Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。结构门禁验证每个包的发布映射后,产物门禁会暂存其 manifest(元数据清单)声明的 `lib/` 文件,在 plain Node 下导入已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,若 companion 导入未声明的运行时分片,门禁就会在发布前失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-semantic-session-checkpoints.md: 4bca02fe3893ac39621ed79a000ca8f86db4ff67
|
||||
2026-07-21-semantic-session-checkpoints.zh.md: 1f187eb6448a3c9ca6784ec2bddd7295be2706d7
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Semantic session checkpoints
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-semantic-session-checkpoints.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Persistence buffered every synchronous `session/event` until the loop's final turn checkpoint. A turn is the correct conversational transaction, but it is too coarse as the only crash-recovery point: a hard crash during a long model request or tool call could discard the whole in-flight turn, including the request envelope needed to identify what had been attempted. A tool call with no result was also repaired with one undifferentiated interruption error, so the resumed model could not tell whether execution had started and could retry a side effect blindly.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-session-checkpoint-policy` owns semantic durability barriers as a zero-config plugin beside a persistence backend. It wraps `llm/stream` lazily and flushes the live session after `request/header` is logged but before the adapter stream is constructed. It wraps top-level `tools/execute` after ordered pre-execute policy and flushes the recorded `tool/call` before the tool body; nested dispatches reuse the outer model-visible call. It flushes at `agent/post-step` after the assistant message and ordered results are recorded. The loop's existing final `turn/end` checkpoint remains the closing boundary.
|
||||
|
||||
Persistence and checkpoint scheduling remain separate Cordis plugins. A backend makes requested `session/flush` boundaries durable but does not choose them; loading it without this policy is valid and retains the loop's coarser checkpoints. First-party persisted apps and runtimes explicitly mount both, while a specialized deployment may intentionally omit or replace the policy. Registration order governs whether events appended by other `agent/post-step` listeners join this checkpoint; the loop-owned assistant message and ordered results always precede the event.
|
||||
|
||||
Checkpoint failure and cancellation are fail-closed at effect boundaries. A rejected request checkpoint prevents adapter dispatch; a rejected tool checkpoint becomes an error result without invoking the tool body. If cancellation lands while the tool checkpoint is pending, the policy rechecks the signal and returns the canonical `ABORTED_BEFORE_DISPATCH` result. A rejected post-step checkpoint stops continuation before another model request. Persistence serialization continues to belong to the coordinator, so concurrent tool checkpoints cannot duplicate event sequences.
|
||||
|
||||
The ACP app owns its bridge, checkpoint policy, and persistence backend in one ordered Cordis effect. Cordis unloads sibling plugin effects concurrently, so independent mounts would let persistence detach while bridge teardown was still closing an interrupted turn. The composite lifecycle unloads the bridge first, waits for its agents to quiesce and flush the real `step/end` and `turn/end`, then removes checkpoint scheduling and persistence.
|
||||
|
||||
Crash repair distinguishes durable evidence. An assistant tool request without a `tool/call` becomes `TOOL_NOT_STARTED` and may be retried if still needed. A durable `tool/call` without a result becomes `TOOL_OUTCOME_UNKNOWN`; its model-visible result permits retry only for read-only or idempotent operations and directs the model to verify external state or ask the user before deciding about side-effecting work. A provider that supports idempotency keys can receive the stable `callId`, but the Harness does not claim generic exactly-once effects.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Flushing every event or streaming chunk minimizes loss but turns local append and `fsync` latency into the hot path and destabilizes streaming throughput. Moving the barriers into `agent-loop` prevents omission for that loop but hides checkpoint policy inside the mechanism and removes Cordis-level replacement and ordering. Keeping turn-only flush preserves throughput but loses the request and execution intent needed for safe recovery. Automatically retrying every unmatched call is safe only for a subset of tools and can duplicate irreversible effects.
|
||||
|
||||
## Consequences
|
||||
|
||||
Hard-crash recovery retains the complete model request, durable tool intent, and complete settled step at the nearest semantic boundary while allowing partial streaming chunks since the previous boundary to remain lossy. Default CLI, TUI, ACP, Python SDK runtime, headless persistence tests, and JSON-RPC compositions mount the policy with their persistence backend. Unit tests cover ordering, cancellation during a checkpoint, fail-closed behavior, nested dispatch, disposal, and Loader shape; a real child process killed with `SIGKILL` proves request and tool-intent recovery through JSONL, and the shared persistence contract proves both recovery classifications across backends. Keyless ACP snapshots prove both that retry-risk guidance reaches resumed history and the next model turn and that graceful cancellation persists the loop's real closing boundaries.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 语义会话检查点
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-semantic-session-checkpoints.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
持久化机制会缓冲所有同步 `session/event`,直到 agent loop(智能体循环)执行最后的轮次检查点才写入。一个轮次是正确的对话事务,但作为唯一的崩溃恢复点过于粗粒度:如果在耗时的模型请求或工具调用期间发生硬崩溃,整个进行中的轮次都可能丢失,其中包括识别已尝试操作所需的请求封套。系统还会使用同一种不作区分的中断错误,修复没有结果的工具调用,因此恢复运行的模型无法判断调用是否已经开始,可能会盲目重试带有副作用的操作。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。它还会在 `agent/post-step` 时刷新会话,此时模型消息与按序结果都已记录。现有的最终 `turn/end` 检查点仍是轮次的收尾边界。
|
||||
|
||||
持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/post-step` 监听器追加的事件是否会纳入本检查点;循环自身记录的助手消息与有序结果始终先于该事件。
|
||||
|
||||
检查点失败与取消在副作用边界上采取失败关闭策略。请求检查点被拒绝时,系统不会分发给适配器;工具检查点被拒绝时,系统会返回错误结果,不调用工具主体。如果在工具检查点等待期间收到取消,策略会重新检查信号,并返回标准的 `ABORTED_BEFORE_DISPATCH` 结果。步骤后检查点被拒绝时,系统会在发起下一个模型请求前停止继续执行。持久化写入的串行化仍由协调器负责,因此并发的工具检查点不会产生重复的事件序号。
|
||||
|
||||
ACP(Agent Client Protocol)应用在一个有序 Cordis effect 中统一持有其桥接层、检查点策略与持久化后端。Cordis 会并发卸载同级插件的 effect;如果分别加载,桥接层仍在为被中断的轮次收尾时,持久化后端就可能已经卸载。组合生命周期会先卸载桥接层,等待其各 agent 达到静止,并刷新真实的 `step/end` 与 `turn/end`,再移除检查点调度与持久化。
|
||||
|
||||
崩溃修复会区分持久化证据。如果模型发出了工具请求,却没有 `tool/call`,系统会将其标记为 `TOOL_NOT_STARTED`;如果仍有需要,可以重试。如果持久化的 `tool/call` 没有结果,系统会将其标记为 `TOOL_OUTCOME_UNKNOWN`;对应的模型可见结果只允许重试只读或幂等操作,并指示模型在决定是否重试有副作用的工作前,先验证外部状态或询问用户。支持幂等键的模型提供方可以获取稳定的 `callId`,但 Harness 不承诺通用的副作用恰好执行一次保证。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
刷新每个事件或流式分片虽能尽可能减少丢失,但会把本地追加与 `fsync` 延迟带入热路径,破坏流式输出的吞吐稳定性。将这些屏障放入 `agent-loop`,虽能防止该循环漏装,却会将检查点策略隐藏在机制中,并失去 Cordis 层的替换与排序能力。仅保留轮次刷新可以维持吞吐量,但会丢失安全恢复所需的请求与执行意图。自动重试所有未匹配调用只对部分工具安全,可能会重复不可逆的副作用。
|
||||
|
||||
## 后果
|
||||
|
||||
发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACP、Python SDK 运行时、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、检查点期间的取消、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化契约则证明各后端都支持这两种恢复分类。无密钥 ACP 快照既证明重试风险指引会进入恢复后的历史记录与下一个模型轮次,也证明取消流程正常收尾时,系统会持久化由循环实际生成的闭合边界。
|
||||
@@ -18,6 +18,14 @@ Background bash tasks carry an opaque owner token equal to the owning session id
|
||||
|
||||
Connection teardown clears the live map, settles each pending prompt as cancelled, and disposes all `AgentHandle`s in parallel. Each handle stops and awaits its loop, flushes the session while attached, unregisters the agent, and removes the session. Teardown is memoized and shared by client disconnect and plugin disposal.
|
||||
|
||||
## Protocol and workspace scope
|
||||
|
||||
[ACP v1 expressly permits several concurrent sessions on one connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24), and each new session carries its own primary `cwd`. This bridge implements that session-level multiplexing, including different primary workspaces as recorded by the [per-session cwd decision](../architecture/2026-07-02-fs-per-session-cwd.md); it does not create one agent subprocess per session.
|
||||
|
||||
A multi-root project inside one session is a separate optional capability: ACP defines the [effective roots as the primary `cwd` plus `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367). [Zed sends the remaining project work directories only when the agent advertises that capability](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1139-L1145), otherwise it [drops them from the session request](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1454-L1472). The bridge does not advertise this capability and rejects non-empty values, as recorded in its [known limitations](../../../../packages/ui/acp/README.md#known-limitations-and-deferred-work), so a current Zed multi-root project reaches it with only the first work directory.
|
||||
|
||||
[The standard transport is one editor-launched agent subprocess per stdio connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42); multiple editor connections therefore require multiple subprocesses or a custom transport, while this decision guarantees multiple sessions within one connection. Within that connection, `ctx.sandboxPolicy` resolves every session's `cwd` as its own `workspace-write` root, so the shared bash and filesystem services can serve concurrent projects without granting cross-project writes. This does not add ACP `additionalDirectories`; it removes the process-wide root limit from the already-supported one-primary-root-per-session path.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**One live session per connection** — rejected. It adds process overhead and contradicts the target client's multi-session shape without removing multiplexing needs from the editor.
|
||||
|
||||
@@ -16,7 +16,7 @@ Two forces shape the design. First, compaction policy and reusable token measure
|
||||
|
||||
Per the [capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently:
|
||||
|
||||
1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*.
|
||||
1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, the `compact/*` session events, and the canonical checkpoint message source. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*.
|
||||
2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, post-step pressure, and canonical context-overflow recovery. `summarize()` is its sole subclass hook; pricing and replay stay with the meter.
|
||||
3. **Model-free companion** — `@deepseek-ai/dsh-compact-tool-result-prune`: a concrete optional service that rewrites oversized current `tool/result` nodes before the backend selects a summary range. It is not a second compaction implementation and does not implement `CompactService`.
|
||||
4. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first.
|
||||
@@ -69,13 +69,14 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint
|
||||
|
||||
### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary
|
||||
|
||||
Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `surfaceOp: { op: 'replace', start, end }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed entries *and* the bookkeeping events. The `compact/*` events are pure log records (lock + provenance). The surface mutation sits **inside** the lock — `compact/end` is the last event appended:
|
||||
Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `source: COMPACT_CHECKPOINT_SOURCE` and `surfaceOp: { op: 'replace', start, end }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed entries *and* the bookkeeping events. The interface exports that source and `isCompactCheckpointSource()` so consumers recognize a persisted or cloned checkpoint without depending on backend package identity. The `compact/*` events are pure log records (lock + provenance). The surface mutation sits **inside** the lock — `compact/end` is the last event appended:
|
||||
|
||||
```
|
||||
compact/start → log-only. Acquires the lock.
|
||||
[summarize older range via the backend]
|
||||
compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count.
|
||||
user/message → surfaceOp { op:'replace', start, end }. THE surface mutation (framed summary).
|
||||
user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }.
|
||||
THE surface mutation (framed summary).
|
||||
deriveMessages() renders it as a user-role message.
|
||||
compact/end → log-only. Releases the lock (carries `error` on a recoverable failure).
|
||||
```
|
||||
@@ -84,7 +85,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab
|
||||
|
||||
### Checkpoint framing + incremental merge (backend-private)
|
||||
|
||||
The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises only that one replacement user message carries the possibly framed summary.
|
||||
The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises that one replacement user message carries the possibly framed summary and uses the canonical checkpoint source.
|
||||
|
||||
### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy
|
||||
|
||||
@@ -117,7 +118,7 @@ Two failure paths, both documented:
|
||||
- **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, and `compact-tool-result-prune` supplies optional deterministic rewriting. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred.
|
||||
- **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload.
|
||||
- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry.
|
||||
- **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject.
|
||||
- **`dsh-compact`** owns `COMPACT_CHECKPOINT_SOURCE`, `isCompactCheckpointSource(source)`, `toolPairingBalancedBefore(session, seq)`, and `toolPairingBalancedAfter(session, seq)`. The marker identifies replacement summaries across backend implementations. The cached surface-edge checks prevent `compactRegion` and `compactIfNeeded` from splitting a tool-call/result pair, validate current membership by seq, answer both edges from one per-cut balance sequence, and reject stale or missing seqs and orphan results.
|
||||
- **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call; validated replacements remain turn-enclosed rewrites.
|
||||
- **Wiring**: `examples/tui-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy.
|
||||
|
||||
|
||||
@@ -14,15 +14,15 @@ The lifecycle has two distinct classes of content. The initial applicable chain
|
||||
|
||||
The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. `@deepseek-ai/dsh-agent-core` mounts it for both product front doors and forwards its config. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability.
|
||||
|
||||
The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes call `lstat` before `resolve`, so a repository-owned final-component symlink is rejected rather than followed outside the workspace. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. Once `lstat` identifies a regular-file winner, a provider exception or disagreement during resolve/stat is classified as unavailable: it is neither interpreted as a deletion nor allowed to fall through to a lower-priority candidate.
|
||||
The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope.
|
||||
|
||||
### File Names And Precedence
|
||||
|
||||
The default per-directory candidate list is `['AGENTS.md', 'CLAUDE.md']`. The list is configurable as `instructionFileCandidates`, and `AGENTS.md` is an ordinary first candidate rather than a hidden priority. In one directory, only the first existing regular-file candidate loads. With defaults, `AGENTS.md` is native and `CLAUDE.md` is a compatibility fallback.
|
||||
The default per-directory candidate list is `['AGENTS.md', 'CLAUDE.md']`. The list is configurable as `instructionFileCandidates`, and `AGENTS.md` is an ordinary first candidate rather than a hidden priority. In one directory, only the first existing regular-file candidate loads. With defaults, `AGENTS.md` is native and `CLAUDE.md` is a compatibility fallback. A second list, `localInstructionFileCandidates` (default `['AGENTS.local.md', 'CLAUDE.local.md']`), loads an additive local overlay after the base file in the same directory; the [default local overlay](2026-07-21-local-instruction-overlay.md) owns that decision.
|
||||
|
||||
Candidate entries are same-directory file names. Empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. Lowercase names, local variants, and other same-directory names can be opted into explicitly; rule directories and import semantics are outside this contract.
|
||||
Candidate entries are same-directory file names. Empty entries, `.`/`..`, and entries containing `/` or `\` are ignored. Other same-directory names can be opted into explicitly; rule directories and import semantics are outside this contract.
|
||||
|
||||
The user-global file is fixed at `$DSH_HOME/AGENTS.md` and is not affected by `instructionFileCandidates`. `$DSH_HOME` defaults to `~/.dsh`, matching the harness-level home role of `~/.codex` or `~/.claude` rather than introducing a plugin-specific home. Tilde expansion and the default live in `dsh-paths` so future harness features share the same convention.
|
||||
The user-global file is fixed at `$DSH_HOME/AGENTS.md`, is not affected by either candidate list, and has no local overlay. `$DSH_HOME` defaults to `~/.dsh`, matching the harness-level home role of `~/.codex` or `~/.claude` rather than introducing a plugin-specific home. Tilde expansion and the default live in `dsh-paths` so future harness features share the same convention.
|
||||
|
||||
### Baseline Prefix
|
||||
|
||||
@@ -46,7 +46,7 @@ Shell commands are not discovery triggers. Local bash calls start fresh shells,
|
||||
|
||||
### Duplicate Suppression And Change Detection
|
||||
|
||||
Every dynamic workspace context event stores versioned metadata with `{ action, scope, path, previousPath?, digest? }`, where `digest` is SHA-1 over the loaded content. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state.
|
||||
Every dynamic workspace context event stores versioned metadata with `{ action, scope, path, digest? }`, where `digest` is SHA-1 over the loaded content. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state.
|
||||
|
||||
At reconciliation time the plugin scans plugin-owned `context/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `context/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy.
|
||||
|
||||
@@ -78,10 +78,10 @@ There is intentionally no watcher. Detection occurs at the next successful struc
|
||||
|
||||
Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries.
|
||||
|
||||
Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, delimiter escaping, and symlink rejection reduce risk but do not eliminate prompt injection. Permission and sandbox layers treat workspace files as data rather than authority.
|
||||
Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk).
|
||||
|
||||
The system is event-driven rather than watch-driven. Edits are not visible at the exact filesystem mutation instant unless that mutation goes through a structured tool; externally changed files are noticed on the next successful structured touch or resume. This keeps the design deterministic and provider-neutral.
|
||||
|
||||
## Deferred
|
||||
|
||||
Bash-derived path reporting, recursive startup scans, file watchers, lowercase defaults, `.claude/CLAUDE.md`, `.claude/rules/*.md`, import directives, ACP `additionalDirectories`, trust acknowledgements, and model-generated summaries are deferred. Same-directory private variants can be configured today; directory rule systems and imports need their own precedence and trust designs.
|
||||
Bash-derived path reporting, recursive startup scans, file watchers, lowercase defaults, `.claude/CLAUDE.md`, `.claude/rules/*.md`, import directives, ACP `additionalDirectories`, trust acknowledgements, and model-generated summaries are deferred. Project-directory `.local.` overlays now load by default (the [default local overlay](2026-07-21-local-instruction-overlay.md) owns that decision); a user-global overlay, directory rule systems, and imports still need their own precedence and trust designs.
|
||||
|
||||
@@ -50,4 +50,4 @@ The catalog is deterministic for a fixed root set and runtime registration revis
|
||||
|
||||
## Deferred
|
||||
|
||||
Forked skill contexts (`context: fork`), direct user/slash invocation (`user-invocable`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields.
|
||||
Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields, and the `user-invocable` frontmatter field is likewise unparsed. Direct user invocation itself ships as a consumer-side affordance instead: the TUI front door offers a manual `/skill:<name>` command over the registry's existing `list()` and `get()` methods, without a registry, provider, or tool contract change — see [the TUI skill slash command](2026-07-21-tui-skill-slash-command.md).
|
||||
|
||||
@@ -12,7 +12,7 @@ Confinement alone leaves two gaps. A denial with no escalation path is terminal
|
||||
|
||||
## Decision
|
||||
|
||||
One seam, one per-platform chain of local backends, one consumer, and two levers on top: a per-call escalation path and per-session runtime modes. Everything below composes from the leaf `cordis.yml`; nothing touches `agent-loop`. The scope is deliberately bounded: the phases this Agent Note names but does not design — per-session workspace root, cross-family fs enforcement, the `subagent-acp` consumer, more environments, a Windows chain — are listed under § Deferred phases, each a follow-up design, not a config knob.
|
||||
One seam, one per-platform chain of local backends, one consumer, and two levers on top: a per-call escalation path and per-session runtime modes. Everything below composes from the leaf `cordis.yml`; nothing touches `agent-loop`. Cross-family fs enforcement and per-session workspace roots landed as follow-ups on the same policy carrier; the remaining phases — the `subagent-acp` consumer, more environments, and a Windows chain — stay under § Deferred phases.
|
||||
|
||||
### How a deployment uses it
|
||||
|
||||
@@ -48,7 +48,7 @@ OS subprocess confinement applies to the bash executor, including hook commands,
|
||||
|
||||
#### The seam: `ctx.sandbox`
|
||||
|
||||
`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxPolicy` (mode + workspace root).
|
||||
`dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxExecutionPolicy` (the complete per-capability-call mode + workspace root), and `SandboxPolicy` (the confined provider subset).
|
||||
|
||||
Policy rides each CALL, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is a new call with a wider policy — inexpressible under a config-fixed provider mode.
|
||||
|
||||
@@ -74,9 +74,9 @@ The model's view is result facts only: the static tool description explains the
|
||||
|
||||
#### Escalation: one approved wider retry after a denial
|
||||
|
||||
`BashExecRequest.sandboxMode` is an optional per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` advertises whether the mounted executor can honor it, so only a confining composition exposes escalation. The seam accepts any explicit mode; the tool owns the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined.
|
||||
`BashExecRequest.sandboxPolicy` is an optional complete per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` remains the capability fact advertising whether the mounted executor can honor that policy, so only a confining composition exposes escalation. The seam accepts any explicit policy; the tool owns session resolution and the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined.
|
||||
|
||||
`SandboxBashExecutor.resolve()` stamps the effective mode — escalation grant > session override > configured default — so `run()`/`start()` read the spec, never the config. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` classifies stderr and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects.
|
||||
`ctx.sandboxPolicy.resolve()` stamps the complete execution policy — explicit escalation mode > session override > configured default, with `SessionHeader.cwd` > configured fallback root — before the executor runs. `SandboxBashExecutor.resolve()` retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so `run()`/`start()` never read mutable session state. Per-process wrap facts are keyed by the returned `BashProcess`; `onProcessDone()` classifies stderr and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects.
|
||||
|
||||
When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted.
|
||||
|
||||
@@ -115,16 +115,15 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s
|
||||
|
||||
### Testing
|
||||
|
||||
- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes.
|
||||
- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip.
|
||||
- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes.
|
||||
- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip.
|
||||
- **With-key:** drive a real model, runner, bridge answerer, and disk effect through granted and rejected escalation; unavailable credentials or runners self-skip.
|
||||
- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. Snapshot mode starts unconfined so unrelated fixtures remain platform-independent; policy scenarios switch explicitly. Real denial stderr stays on platform tests because its dialect is runner-specific.
|
||||
- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins a successful workspace-write mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent, and policy scenarios switch explicitly.
|
||||
|
||||
## Deferred phases
|
||||
|
||||
Each phase gets its full design when picked up, validated against the code at that time, and lands with unit, real-API e2e, and snapshot coverage at the tiers it touches.
|
||||
|
||||
- **Per-session workspace root** — the executor's write boundary stays config-fixed for its lifetime while each ACP session has its own cwd; a per-session root rides the same per-call policy carrier once designed. Centralizing the root on `ctx.sandboxPolicy` (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)) is the groundwork.
|
||||
- **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence).
|
||||
- **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container).
|
||||
- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures.
|
||||
@@ -163,6 +162,7 @@ What shipped pins — the tiers in Testing hold each:
|
||||
- N idle-time flips produce at most one anchored event per knob (a net-zero sequence anchors none — a no-op push from a client echoing current selections records nothing); an approval-policy switch is narrated in at most one coalesced notice; a mid-turn sandbox switch is honored by the next call's stamp.
|
||||
- A resumed session's overrides apply and are reported to the editor with no special-casing; a default changed while the process was down is narrated before the session's first new request, attributed to the operator.
|
||||
- Two concurrent sessions never see each other's state, notices, or config options.
|
||||
- Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd.
|
||||
- `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/pre-step`, `agent/prompt-submit`, and the ACP handler surface.
|
||||
|
||||
Costs and accepted limits:
|
||||
@@ -199,7 +199,7 @@ Costs and accepted limits:
|
||||
In-repo precedents this design copies or contrasts with:
|
||||
|
||||
- [The capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md) — the interface/implementation/consumer split and the "don't split preemptively" timing rule the second consumer satisfied.
|
||||
- The `dsh-bash` request/spec split ([the bash vocabulary catalog](../../../../docs/core-data-structures/bash.md)) — the per-call carrier template `sandboxMode` rides, and the explicit-`resolve()` defaulting convention.
|
||||
- The `dsh-bash` request/spec split ([the bash vocabulary catalog](../../../../docs/core-data-structures/bash.md)) — the complete `sandboxPolicy` rides its per-call carrier, and the explicit-`resolve()` defaulting convention.
|
||||
- [The approval seam Agent Note](2026-07-06-approval-seam.md) — the channel escalation asks through; its answerer waterfall, audit pair, and one-package rationale are recorded there.
|
||||
- [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [the turn-enclosure invariant](../architecture/2026-06-15-turn-enclosure-invariant.md) — the log-as-store foundation the per-session modes fold over, and the commit boundary the anchoring design obeys.
|
||||
- [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` vocabulary the escalation gate deliberately does not reuse (an escalating call has no pre-execute moment of its own).
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-14-cross-family-fs-sandbox.md: 0897695cc14b7573ebb53f3ffa6a460652882b37
|
||||
2026-07-14-cross-family-fs-sandbox.zh.md: 15de061a0d2b18392f839c927e9b0f5d0cacf28b
|
||||
2026-07-14-cross-family-fs-sandbox.md: e8a59be345b52f7684c574134b37f48bc49843fc
|
||||
2026-07-14-cross-family-fs-sandbox.zh.md: 92bc5a495a7c20a08bc85ef9dbf1a1beffe6264f
|
||||
|
||||
@@ -22,9 +22,10 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching
|
||||
|
||||
- `Config`: `mode` (the closed `SandboxMode` union, default `read-only`) and `workspaceRoot` (default the process cwd, resolved absolute). Misconfiguration fails loud at load.
|
||||
- The per-session override event `sandbox/mode`, with its pure fold (`effectiveSandboxMode(events)`), its write path (`setSandboxMode(session, mode)`), and `SANDBOX_MODES`. The event is policy state — consumed by two families — so it lives here, not in either capability's seam. Its shape and log-only semantics match the `approval/*` precedent.
|
||||
- `defaultMode` / `workspaceRoot` accessors the enforcing implementations read for their resolve fallback and boundary.
|
||||
- `resolve({ session?, mode? })`, which returns a complete per-call `SandboxExecutionPolicy`: explicit approved mode > the session fold > `defaultMode`, and the session's immutable cwd > configured `workspaceRoot` fallback.
|
||||
- `defaultMode` / `workspaceRoot` accessors retained as deployment fallbacks and the capability-advertisement fact.
|
||||
|
||||
`dsh-bash-sandbox` carries no sandbox config of its own — it injects `sandboxPolicy` and reads the default from it; its `resolve()` precedence is unchanged (escalation grant > per-call stamp > default). `dsh-tool-bash` and `dsh-tool-fs` fold the session's `sandbox/mode` with `effectiveSandboxMode` to stamp each call; `dsh-permission` presets and the ACP bridge write through the relocated setter. The seam that owns bash execution no longer depends on `dsh-session` at all — the session dependency moved to the policy package with the fold.
|
||||
`dsh-bash-sandbox` carries no sandbox config of its own — it injects `sandboxPolicy` and uses its deployment fallback only for direct calls. `dsh-tool-bash` and `dsh-tool-fs` pass the active session to `ctx.sandboxPolicy.resolve()`, so both receive the same effective mode and cwd root on every call; `dsh-permission` presets and the ACP bridge write through the relocated setter. The seams that own bash and fs execution remain session-free — the session dependency lives in the policy package and tool consumers.
|
||||
|
||||
### `dsh-fs-sandbox` — enforcement inside the provider
|
||||
|
||||
@@ -34,13 +35,13 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching
|
||||
- `workspace-write` fences the canonicalized target against the writable-root set — `writableRoots(policy)` in `dsh-sandbox`: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), each realpathed — the SAME set the Seatbelt profile grants, so the fs fence is the fourth dialect of one mode meaning alongside the bwrap/Landlock/Seatbelt profiles, and "the write tool cannot write `/tmp` but bash can" asymmetries cannot arise. Canonical spellings take a lexical containment fast path; when Windows exposes one directory through different casing or long-name/8.3 spellings, an ancestor walk compares filesystem identity rather than weakening the boundary to textual prefix guesses. The target is re-canonicalized (`resolve` realpaths the deepest existing ancestor) immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
|
||||
- `danger-full-access` delegates unfenced.
|
||||
|
||||
A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `sandboxMode` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxMode`); the seam stays session-free (the caller stamps, exactly as `resolve` takes a cwd), and the bare local backend carries-and-ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth.
|
||||
A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `SandboxExecutionPolicy` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxPolicy`); the seam stays session-free, and the bare local backend ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth.
|
||||
|
||||
The threat model is stated in the package README: a policy fence in trusted code over model-controlled paths, not a kernel boundary — the operations are the seam's own, only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface (the `code-runtime` "containment, not a security boundary" precedent). Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job. The residual resolve-to-syscall race is narrowed by the in-place re-canonicalization and eliminated only by platform primitives (`openat2` `RESOLVE_BENEATH`) not worth their portability cost here.
|
||||
|
||||
### Tool parity — one denial marker, one escalation flow
|
||||
|
||||
`dsh-tool-fs` stamps the effective mode onto each mutation and maps `FS_SANDBOX_DENIED` to the marker the model already knows from bash: `[sandbox: file access denied under <mode> mode]`. When `ctx.fs.sandboxMode` reports a confining mode at registration, `write` and `edit` advertise the same `sandbox_permissions` + `justification` fields, teach the same same-turn retry, and resolve the same `ctx.approval` request before executing — the four outcomes and their verbatim fail-closed texts carried over from [the sandbox Agent Note](2026-07-06-sandbox.md) § Escalation (strict widening checked at execution against the call's effective mode; a grant consumed by the one call that asked; no new session events).
|
||||
`dsh-tool-fs` resolves the active session's complete policy onto each mutation and maps `FS_SANDBOX_DENIED` to the marker the model already knows from bash: `[sandbox: file access denied under <mode> mode]`. When `ctx.fs.sandboxMode` reports a confining mode at registration, `write` and `edit` advertise the same `sandbox_permissions` + `justification` fields, teach the same same-turn retry, and resolve the same `ctx.approval` request before executing — the four outcomes and their verbatim fail-closed texts carried over from [the sandbox Agent Note](2026-07-06-sandbox.md) § Escalation (strict widening checked at execution against the call's effective mode; a grant changes only that call's mode and retains its session root; no new session events).
|
||||
|
||||
The shared pieces live in `dsh-sandbox`, which owns the mode types: `WIDER_MODES`, the escalation-target enum, the argument-pairing validation, the denial/hint marker builders, and `approveEscalation` — the ordered fail-closed choreography. `approveEscalation` takes a minimal STRUCTURAL approver (`EscalationApprover`, generic over the agent and call-id types), not the approval service type, so `dsh-sandbox` gains no dependency on the approval or agent packages: each tool passes its own `ctx.approval`, agent, call id, and tool name as ingredients. `dsh-tool-bash` and `dsh-tool-fs` both use these; the cross-file duplication gate holds the single-sourcing honest.
|
||||
|
||||
@@ -53,7 +54,8 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the
|
||||
### Out of scope
|
||||
|
||||
- **Network policy for `ctx.web`** — `SandboxMode` claims file effects only; a web-only network knob while bash `curl` runs free would be a false boundary. Revisit when a bash backend enforces network (bwrap `--unshare-net`, Landlock ABI v4+).
|
||||
- **The `subagent-acp` consumer** and **per-session workspace root** — unchanged deferred phases of the sandbox RFC; centralizing the root in `ctx.sandboxPolicy` is groundwork for the latter, not its design.
|
||||
- **The `subagent-acp` consumer** — unchanged deferred phase of the sandbox RFC.
|
||||
- **Additional writable roots inside one session** — the resolved policy carries one primary `SessionHeader.cwd`; ACP `additionalDirectories` remains a separate bridge and policy design.
|
||||
- **A uniform per-tool sandbox runtime** — remains rejected for the reasons in the sandbox RFC.
|
||||
|
||||
## Alternatives considered
|
||||
@@ -66,7 +68,7 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the
|
||||
- **Per-family policy config with a load-time consistency check** — rejected: two homes for one fact, patched by a check that must enumerate every future enforcing family; the policy service makes drift inexpressible instead of detected.
|
||||
- **Keep the override event in `dsh-bash` as `bash/sandbox-mode`** — rejected: the event is policy state consumed by two families; leaving it bash-named forces `dsh-fs-sandbox` to depend on bash vocabulary. Pre-release, the rename is a same-change move with snapshot re-records, no shims.
|
||||
- **Escalation choreography imported from the approval/agent packages into `dsh-sandbox`** — rejected: it would invert the layering (a base vocabulary package depending on UI/agent packages). The structural approver keeps the logic single-sourced in `dsh-sandbox` while the dependencies stay in the tool layer that already holds them.
|
||||
- **A consolidated mutation-options object on the fs seam** (the shape first sketched for the per-call carrier) — rejected on friction: it churns every `writeText`/`editText` caller and splits `signal` across an options bag for mutations while reads keep it positional. A trailing optional `sandboxMode` matches bash's carry-and-ignore pattern and keeps `signal` symmetric across the seam.
|
||||
- **A consolidated mutation-options object on the fs seam** (the shape first sketched for the per-call carrier) — rejected on friction: it splits `signal` across an options bag for mutations while reads keep it positional. A trailing optional `SandboxExecutionPolicy` matches bash's carry-and-ignore pattern and keeps `signal` symmetric across the seam.
|
||||
- **Extra writable-root grants on `SandboxPolicy` now** — deferred unchanged: `writableRoots()` derives from the mode meaning today; ad-hoc grants are an escalation-scope question the sandbox RFC left open.
|
||||
|
||||
## Consequences
|
||||
@@ -77,6 +79,7 @@ What shipped — the tiers in § Testing hold each:
|
||||
- Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, a new file created under such a symlink, and alias-equivalent root spellings — denies every escape while admitting the same directory identity on real disks.
|
||||
- A denied fs mutation retried once with `sandbox_permissions` + `justification` prompts through the composed approval chain; a grant runs exactly that call under the wider mode and the write lands; rejected/cancelled/unavailable each produce their verbatim fail-closed text and mutate nothing.
|
||||
- One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandbox/mode` fold.
|
||||
- Concurrent sessions with different cwd roots carry different policies through the same service instances; neither family caches one session's root for the next call.
|
||||
- A direct `ctx.fs.writeText` with no per-call stamp is confined at the deployment default.
|
||||
- The escalation fields on `write`/`edit` exist exactly when the mounted `ctx.fs` confines, absent under `dsh-fs-local`.
|
||||
- `agent-loop` is untouched — everything rides `ctx.sandboxPolicy`, the `ctx.fs` seam, `SessionEventMap` merging, and the tool-execution pipeline.
|
||||
@@ -90,5 +93,6 @@ Costs and accepted limits:
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins the default accessors, the fold/setter, the load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-mode fence and the containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, and alias-equivalent spelling) on a real filesystem, plus the per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, the mode stamp, the fold, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` migrate to the relocated policy/kit.
|
||||
- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins deployment fallback, session mode/root resolution, explicit-mode precedence, the fold/setter, load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-policy fence and containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, root-ending-in-separator, and alias-equivalent spelling) on a real filesystem, plus per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, complete policy resolution, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` consume the same policy kit.
|
||||
- Keyless e2e: one real Cordis context creates two agents with different session cwd roots, runs the shipped bash and fs tools concurrently, and world-verifies that own-project writes land while both cross-project writes are denied.
|
||||
- Snapshot: the acp-agent example composes `dsh-sandbox-policy` + `dsh-fs-sandbox`; the pinned header carries the fs escalation fields and the `sandbox/mode` event name, re-recorded once.
|
||||
|
||||
@@ -22,9 +22,10 @@ Status: implemented
|
||||
|
||||
- `Config`:`mode`(封闭的 `SandboxMode` 联合,默认 `read-only`)与 `workspaceRoot`(默认进程 cwd,解析为绝对路径)。配置错误在加载时高声失败。
|
||||
- per-session 覆盖事件 `sandbox/mode`,连同它的纯折叠(`effectiveSandboxMode(events)`)、写入路径(`setSandboxMode(session, mode)`)与 `SANDBOX_MODES`。该事件是策略状态——被两个家族消费——所以它住在这里,而不在任一能力的 seam 里。它的形状与仅日志(log-only)语义遵循 `approval/*` 的先例。
|
||||
- `defaultMode` / `workspaceRoot` 访问器,供执行实现读取其 resolve 回退值与边界。
|
||||
- `resolve({ session?, mode? })` 返回完整的单次调用 `SandboxExecutionPolicy`:显式批准的模式 > 会话折叠结果 > `defaultMode`,而会话中不可变的 cwd > 配置的 `workspaceRoot` 回退值。
|
||||
- 保留 `defaultMode` / `workspaceRoot` 访问器,作为部署回退值与能力宣告依据。
|
||||
|
||||
`dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy` 并从中读取默认值;其 `resolve()` 优先级不变(升级授权 > per-call 盖章 > 默认)。`dsh-tool-bash` 与 `dsh-tool-fs` 用 `effectiveSandboxMode` 折叠会话的 `sandbox/mode` 以对每次调用盖章;`dsh-permission` 预设与 ACP bridge 经由迁移后的 setter 写入。拥有 bash 执行的那个 seam 不再依赖 `dsh-session`——会话依赖随折叠一起迁到了策略包。
|
||||
`dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy`,仅在直接调用时使用其中的部署回退值。`dsh-tool-bash` 与 `dsh-tool-fs` 把当前会话传给 `ctx.sandboxPolicy.resolve()`,因此两者每次调用都会取得相同的生效模式与 cwd 根目录;`dsh-permission` 预设与 ACP bridge 经由迁移后的 setter 写入。拥有 bash 与 fs 执行的 seam 仍不依赖会话——会话依赖归策略包与工具消费方所有。
|
||||
|
||||
### `dsh-fs-sandbox`——在提供方内部执行
|
||||
|
||||
@@ -34,13 +35,13 @@ Status: implemented
|
||||
- `workspace-write` 把规范化后的目标围栏于可写根集合——`dsh-sandbox` 中的 `writableRoots(policy)`:工作区根加上平台临时目录(`/tmp`、`os.tmpdir()`),各自 realpath——与 Seatbelt profile 授予的是同一个集合,所以 fs 围栏是这一个模式含义在 bwrap/Landlock/Seatbelt profile 之外的第四种方言,因此不会出现「write 工具不能写 `/tmp` 而 bash 能」的不对称。规范化路径写法采用词法包含的快速路径;当 Windows 以大小写不同的路径、长文件名或 8.3 短文件名表示同一目录时,系统会逐级遍历祖先目录并比较文件系统身份,而不会把边界弱化为依据文本前缀猜测包含关系。目标在委托前被立即重新规范化(`resolve` 对最深的既有祖先做 realpath),因此自工具解析该目标以来被换出的祖先符号链接会被捕获。
|
||||
- `danger-full-access` 不加围栏地委托。
|
||||
|
||||
拒绝是结构化的 `FS_SANDBOX_DENIED`,携带生效模式——区别于 `FS_PERMISSION_DENIED`(宿主 EACCES 是世界在拒绝;这里是策略在拒绝)。无文本推断:进程内围栏确切知道它拒绝了什么。per-call 载体是 `writeText`/`editText` 上一个末尾可选的 `sandboxMode`(文件系统侧对应 `BashExecRequest.sandboxMode`);该 seam 保持无会话依赖(由调用方盖章,正如 `resolve` 接收一个 cwd),而裸的本地后端携带并忽略它。`FileSystem.sandboxMode` 是能力事实(在基类与 `fs-local` 上为 `undefined`,在 `SandboxedFileSystem` 上为默认值),所以工具层按组合真相来宣告升级。
|
||||
拒绝是结构化的 `FS_SANDBOX_DENIED`,携带生效模式——区别于 `FS_PERMISSION_DENIED`(宿主 EACCES 是世界在拒绝;这里是策略在拒绝)。无文本推断:进程内围栏确切知道它拒绝了什么。per-call 载体是 `writeText`/`editText` 上一个末尾可选的 `SandboxExecutionPolicy`(文件系统侧对应 `BashExecRequest.sandboxPolicy`);该 seam 保持无会话依赖,而裸的本地后端会忽略它。`FileSystem.sandboxMode` 是能力事实(在基类与 `fs-local` 上为 `undefined`,在 `SandboxedFileSystem` 上为默认值),所以工具层按组合真相来宣告升级。
|
||||
|
||||
威胁模型写在包 README 里:一道位于可信代码中、针对模型可控路径的策略围栏,而非内核边界——操作是 seam 自身的,只有目标路径不可信,所以「先规范化再判包含」是对这个面的完整答案(`code-runtime` 的「containment, not a security boundary」先例)。对不可信代码的内核级隔离仍是 `ctx.bash` 的职责。resolve 到系统调用之间残留的竞态被就地重新规范化收窄,只有平台原语(`openat2` `RESOLVE_BENEATH`)能彻底消除它,而那在此不值其可移植性代价。
|
||||
|
||||
### 工具对等——一个拒绝标记、一条升级流程
|
||||
|
||||
`dsh-tool-fs` 把生效模式盖章到每次变更上,并将 `FS_SANDBOX_DENIED` 映射为模型已从 bash 认识的标记:`[sandbox: file access denied under <mode> mode]`。当 `ctx.fs.sandboxMode` 在注册时报告一个受限模式,`write` 与 `edit` 宣告相同的 `sandbox_permissions` + `justification` 字段,教授相同的同回合重试,并在执行前解析相同的 `ctx.approval` 请求——四种结果及其逐字的 fail-closed 文案沿用自[沙箱 RFC](2026-07-06-sandbox.md) § Escalation(严格加宽在执行时针对调用的生效模式检查;授权由发起它的那一次调用消费;无任何新会话事件)。
|
||||
`dsh-tool-fs` 把当前会话解析成完整策略,并传给每次变更,同时将 `FS_SANDBOX_DENIED` 映射为模型已从 bash 认识的标记:`[sandbox: file access denied under <mode> mode]`。当 `ctx.fs.sandboxMode` 在注册时报告一个受限模式,`write` 与 `edit` 宣告相同的 `sandbox_permissions` + `justification` 字段,教授相同的同回合重试,并在执行前解析相同的 `ctx.approval` 请求——四种结果及其逐字的 fail-closed 文案沿用自[沙箱 RFC](2026-07-06-sandbox.md) § Escalation(执行时根据调用的生效模式检查是否严格加宽;授权只改变当前调用的模式,并保留其会话根目录;不产生任何新会话事件)。
|
||||
|
||||
共享部分住在 `dsh-sandbox`,它拥有模式类型:`WIDER_MODES`、升级目标枚举、参数配对校验、拒绝/提示标记构造器,以及 `approveEscalation`——有序的 fail-closed 编排。`approveEscalation` 接收一个最小的结构化 approver(`EscalationApprover`,对 agent 与 call-id 类型泛型化),而非审批服务类型,所以 `dsh-sandbox` 不获得对 approval 或 agent 包的依赖:每个工具把自己的 `ctx.approval`、agent、call id 与工具名作为原料传入。`dsh-tool-bash` 与 `dsh-tool-fs` 都使用它们;跨文件重复检测门禁确保单一来源不走样。
|
||||
|
||||
@@ -53,7 +54,8 @@ Status: implemented
|
||||
### 范围之外
|
||||
|
||||
- **`ctx.web` 的网络策略**——`SandboxMode` 只声明文件效果;在 bash `curl` 畅通时给一个仅限 web 的网络旋钮会是一道假边界。待某个 bash 后端能执行网络(bwrap `--unshare-net`、Landlock ABI v4+)时再议。
|
||||
- **`subagent-acp` 消费者** 与 **per-session 工作区根**——沙箱 RFC 未变的延后阶段;把根集中到 `ctx.sandboxPolicy` 是后者的铺垫,而非其设计。
|
||||
- **`subagent-acp` 消费者**——沙箱 RFC 中未变的延后阶段。
|
||||
- **单个会话中的额外可写根目录**——解析后的策略携带一个主要 `SessionHeader.cwd`;ACP `additionalDirectories` 仍是独立的 bridge 与策略设计问题。
|
||||
- **统一的 per-tool 沙箱运行时**——因沙箱 RFC 中的理由继续否决。
|
||||
|
||||
## Alternatives considered
|
||||
@@ -66,7 +68,7 @@ Status: implemented
|
||||
- **带加载期一致性校验的 per-family 策略配置**——否决:一个事实两个归属,靠一个必须枚举每个未来执行家族的校验来打补丁;策略服务让漂移不可表达,而非被检测到。
|
||||
- **把覆盖事件留在 `dsh-bash` 里作 `bash/sandbox-mode`**——否决:该事件是被两个家族消费的策略状态;保留 bash 命名会迫使 `dsh-fs-sandbox` 依赖 bash 词汇。预发布阶段,该改名是同一变更内的迁移,附带快照重录,无任何 shim。
|
||||
- **把升级编排从 approval/agent 包导入 `dsh-sandbox`**——否决:那会倒置分层(一个基础词汇包依赖 UI/agent 包)。结构化 approver 让逻辑单一来源于 `dsh-sandbox`,而依赖留在本就持有它们的工具层。
|
||||
- **fs seam 上一个合并的 mutation-options 对象**(per-call 载体最初草拟的形状)——因摩擦被否决:它会搅动每一个 `writeText`/`editText` 调用方,并把 `signal` 拆进变更专用的选项包,而读取仍保持位置参数。一个末尾可选的 `sandboxMode` 匹配 bash 的携带并忽略模式,并使 `signal` 在整个 seam 上保持对称。
|
||||
- **fs seam 上一个合并的 mutation-options 对象**(per-call 载体最初草拟的形状)——因摩擦被否决:它会把 `signal` 拆进变更专用的选项包,而读取仍保持位置参数。一个末尾可选的 `SandboxExecutionPolicy` 匹配 bash 的携带并忽略模式,并使 `signal` 在整个 seam 上保持对称。
|
||||
- **现在就在 `SandboxPolicy` 上加额外的可写根授权**——照旧延后:`writableRoots()` 如今由模式含义推导;临时授权是沙箱 RFC 留下的升级作用域问题。
|
||||
|
||||
## Consequences
|
||||
@@ -77,6 +79,7 @@ Status: implemented
|
||||
- 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录、在这样一个符号链接下新建的文件,以及根路径的等价别名形式——在真实磁盘上拒绝每一种逃逸,同时允许文件系统认定为同一目录的路径。
|
||||
- 一个被拒的 fs 变更,携带 `sandbox_permissions` + `justification` 重试一次,会经组合的审批链提示;一次授权让恰好那一次调用在更宽的模式下运行且写入落盘;rejected/cancelled/unavailable 各自产生其逐字的 fail-closed 文案且不做任何变更。
|
||||
- 一次 `permission` 预设切换同时管辖两个家族:会话切换模式后,下一次 bash 调用与下一次 fs 变更都从同一个 `sandbox/mode` 折叠遵循新模式。
|
||||
- cwd 根目录不同的并发会话通过同一组服务实例携带不同策略;两个家族都不会缓存某个会话的根目录供下一次调用使用。
|
||||
- 一次无 per-call 盖章的直连 `ctx.fs.writeText` 会被围栏于部署默认值。
|
||||
- `write`/`edit` 上的升级字段恰好在被挂载的 `ctx.fs` 受限时存在,在 `dsh-fs-local` 下不存在。
|
||||
- `agent-loop` 未被触动——一切都骑在 `ctx.sandboxPolicy`、`ctx.fs` seam、`SessionEventMap` 合并,以及工具执行管线之上。
|
||||
@@ -90,5 +93,6 @@ Status: implemented
|
||||
|
||||
## Testing
|
||||
|
||||
- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住默认访问器、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住 per-mode 围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、模式盖章、折叠、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 迁移到迁移后的策略/工具集。
|
||||
- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住部署回退、会话模式/根目录解析、显式模式优先级、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住按策略执行的围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、以分隔符结尾的根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、完整策略解析、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 使用同一套策略工具集。
|
||||
- 无密钥 e2e:一个真实 Cordis 上下文创建两个 agent,其会话的 cwd 根目录各不相同;系统并发运行正式发布的 bash 与 fs 工具,再通过外部可观察结果验证各自在所属项目中的写入成功,而两次跨项目写入都被拒绝。
|
||||
- 快照:acp-agent 示例组合 `dsh-sandbox-policy` + `dsh-fs-sandbox`;被钉住的 header 携带 fs 升级字段与 `sandbox/mode` 事件名,一次性重录。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-16-persistent-pty-sessions.md: 76354891f557974b953a1b0b805d94330c9f6e69
|
||||
2026-07-16-persistent-pty-sessions.zh.md: 86200d70c6eda815a440c44e8b55457b0424edb6
|
||||
@@ -0,0 +1,171 @@
|
||||
# Agent Note: persistent PTY sessions
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-16-persistent-pty-sessions.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness can run foreground and background commands, edit files, and delegate work, but it cannot continue an interactive terminal conversation across tool calls. Each `bash` foreground run starts a fresh shell, so shell-local cwd, exported variables, virtual-environment activation, functions, job-control state, and interactive child processes end with that call.
|
||||
|
||||
That gap excludes workflows whose state lives in a terminal rather than a file: stepping through `gdb`, exploring in a Python or Node REPL, driving a line-oriented editor such as `ed`, or returning to a shell after interrupting its foreground command. The generic [`ctx.tasks`](../../../../packages/tasks/README.md) runtime retains background-operation handles and output, but it does not provide interactive stdin or terminal semantics.
|
||||
|
||||
The existing `bash`, `read`, `write`, and `edit` tools remain the reliable default for bounded, auditable operations. A PTY is an additional capability for work that genuinely requires terminal state, not evidence that those tools are defective or candidates for removal.
|
||||
|
||||
## Decision
|
||||
|
||||
The optional `packages/pty/` capability family exposes agent-owned, persistent, line-oriented PTY sessions. It follows the repository's [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md), coexists with the existing command and filesystem tools, and does not change `agent-loop`.
|
||||
|
||||
The implementation supports interactive shells and line-oriented REPLs on Linux and macOS. Full-screen terminal applications, keystroke sequences, BEL-triggered control flow, session restoration after process loss, and cross-agent session sharing are explicitly deferred.
|
||||
|
||||
### Package topology
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `dsh-pty` | `PtyService`, branded `PtySessionId`, backend registry, owner-scoped session contract, and result types | `ctx.pty` |
|
||||
| `dsh-pty-local` | [`node-pty`](https://github.com/microsoft/node-pty)-based local backend, platform process inspection, bounded terminal buffer, sandbox resolution, and process-tree supervision | registers a backend on `ctx.pty` |
|
||||
| `dsh-tool-pty` | Six model-facing tools, task-runtime integration for background sends, guidance, and ACP render intents | registers on `ctx.tools` |
|
||||
|
||||
Idle detection is backend behavior, not a second public seam. A remote or container backend may have authoritative readiness signals that do not resemble local `/proc` inspection; every `PtyBackend` therefore returns the common send result while owning its detection mechanism internally.
|
||||
|
||||
### Agent ownership and identity
|
||||
|
||||
`PtyService` stores live sessions process-locally, but every session is owned by the exact `Agent` passed through the tool execution context. The service mints an opaque `PtySessionId`; an optional model-chosen `name` is display metadata and is unique only within that owner. Every operation targets `sessionId`, and `list`/`read`/`signal`/`kill` reject callers other than the owner.
|
||||
|
||||
There are no plugin-load auto-start sessions. `terminal_open` creates a session only during an agent tool call, when ownership and the owning event-sourced session are known. A future declarative startup feature must compose through unpublished agent setup rather than create shared global terminals.
|
||||
|
||||
Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Backend or tool-plugin reload does not orphan sessions: ownership lives in `PtyService` until the agent ends, following the same service-owned-record pattern as [`ctx.tasks`](../../../../packages/tasks/tasks/README.md).
|
||||
|
||||
### Security and process boundary
|
||||
|
||||
A registered `shell` backend constrains how a terminal starts; it does not constrain commands typed after startup. `dsh-pty-local` therefore applies two protections before spawning:
|
||||
|
||||
- It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them.
|
||||
- It requires `ctx.sandbox` and the shared `ctx.sandboxPolicy`. At spawn, the backend resolves the owner's effective session mode over the deployment default and wraps the shell argv once; that mode and workspace root remain the process boundary for the PTY lifetime. `danger-full-access` is the existing explicit unconfined choice rather than a PTY-specific bypass.
|
||||
|
||||
Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary.
|
||||
|
||||
The implementation uses only public `node-pty` capabilities: child PID, `data` and `exit` notifications, `write`, `resize`, and `kill`. It does not assume access to the native master fd or call `waitpid` from TypeScript. Platform process inspectors derive foreground process groups and parent/child identity from `/proc` on Linux and `ps` on macOS.
|
||||
|
||||
### Six model-facing tools
|
||||
|
||||
| Tool | Purpose | Result |
|
||||
|---|---|---|
|
||||
| `terminal_open` | Create an owner-scoped session from a registered backend type | `{ sessionId, name, type, motd }` |
|
||||
| `terminal_send` | Send text, optionally submit Enter, and wait for readiness or register a background task | bounded viewport plus wait and session status; background also returns `taskId` |
|
||||
| `terminal_read` | Read a bounded page from retained scrollback | `{ text, totalLines, lineBegin, lineEnd, truncated }` |
|
||||
| `terminal_signal` | Send one allowed signal to the current foreground process group | `{ delivered, targetPgid }` |
|
||||
| `terminal_close` | Close one session and await process-tree quiescence | `{ killed }` |
|
||||
| `terminal_list` | List the caller's live sessions | owner-scoped session summaries |
|
||||
|
||||
`terminal_send({ sessionId, text, submit?, run_in_background? })` treats `text` as UTF-8 bytes and resolves `submit` to `true` in the tool implementation. When `submit` is true it writes the platform Enter sequence after the text; when false it writes only the text, allowing control characters and REPL fragments without hidden content heuristics.
|
||||
|
||||
Foreground sends return a bounded rendered delta and two independent facts: `waitReason` (`stdin_read | inferred_idle | timeout | session_exit`) and `sessionStatus` (`running` or `exited` with exit code or signal). `session_exit` refers to the PTY's top-level shell process, not an arbitrary foreground command whose status the shell consumes. A timeout never implies process exit.
|
||||
|
||||
With `run_in_background: true`, `dsh-tool-pty` registers the in-flight send on `ctx.tasks` and returns immediately with `taskId`. `task_output(wait: true)` waits, reads incremental output, and records the final result; `task_kill` forwards cancellation as `SIGINT` and escalates only through the PTY backend's owned teardown path. If the task surface is absent, background mode fails before writing input. No PTY-specific `sleep` tool or general wake-up seam is added.
|
||||
|
||||
`terminal_read` pages backward from the newest retained line. The backend enforces both line and UTF-8 byte caps on retained scrollback and the complete returned value, so one oversized line cannot bypass the bound. `truncated` distinguishes retention loss from an ordinary viewport delta.
|
||||
|
||||
`terminal_signal` accepts the closed set `SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP`. The backend resolves the terminal foreground process group at execution time. `SIGKILL` is rejected when that group is the top-level shell, directing the caller to `terminal_close`; a failed group lookup fails the operation instead of signaling a guessed PID.
|
||||
|
||||
### Local readiness detection
|
||||
|
||||
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then runs three bounded fallback tiers. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, and `timeoutMs`.
|
||||
|
||||
On Linux, the inspector reads the shell's terminal foreground PGID from `/proc/<shellPid>/stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1.
|
||||
|
||||
On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path.
|
||||
|
||||
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session.
|
||||
|
||||
`node-pty` data notifications feed one streaming decoder and terminal parser. Parser carry state handles UTF-8 and terminal query sequences split across chunks. The implementation normalizes line-oriented output and detects alternate-screen entry, but it does not promise correct interaction with a full-screen application.
|
||||
|
||||
### Model-visible output and durability
|
||||
|
||||
The existing durable `tool/call` and `tool/result` events are the source of truth for text sent by the model and rendered output returned to it. `terminal_open` returns its MOTD through the logged tool result; foreground `send`/`read`/`list`/`signal`/`close` results are logged the same way. The PTY packages do not duplicate raw byte streams into custom session events.
|
||||
|
||||
Background sends use the existing task completion notice and `task_output` result path, so any output that reaches a later model request is likewise durable. Raw terminal bytes remain bounded process-local state and are neither persisted nor restorable. A future opt-in transcript sink would need its own retention, credential, and privacy contract.
|
||||
|
||||
### Process-tree teardown
|
||||
|
||||
The top-level `node-pty` child is the ownership anchor. On close, the backend stops callbacks, snapshots that PID and its transitive descendants by parent PID in children-first order, sends `SIGTERM`, closes the PTY, waits for quiescence, then sends `SIGKILL` to verified survivors after configurable `disposeGraceMs` and waits for them to leave the process table. Every captured PID includes process-start identity so reuse cannot redirect escalation.
|
||||
|
||||
Teardown reports root exit and survivor cleanup independently. It does not claim success merely because the shell exited; disposal resolves only after no captured tree member remains or returns a structured cleanup failure naming the survivors. Service disposal still clears its backend, reservation, and owner-detacher registries when a close fails. It never broadens ownership to every member of the root PID's POSIX session.
|
||||
|
||||
### Composition and rollout
|
||||
|
||||
The example composition remains opt-in and safe by default:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
'@deepseek-ai/dsh-sandbox-local':
|
||||
'@deepseek-ai/dsh-sandbox-policy':
|
||||
config:
|
||||
mode: workspace-write
|
||||
workspaceRoot: .
|
||||
'@deepseek-ai/dsh-pty':
|
||||
'@deepseek-ai/dsh-pty-local':
|
||||
config:
|
||||
scrollbackLines: 10000
|
||||
scrollbackMaxBytes: 4194304
|
||||
maxReadBytes: 262144
|
||||
pollIntervalMs: 50
|
||||
exactProbeAfterMs: 150
|
||||
idleSilenceMs: 3000
|
||||
timeoutMs: 30000
|
||||
disposeGraceMs: 3000
|
||||
'@deepseek-ai/dsh-tool-pty':
|
||||
```
|
||||
|
||||
The package ships concise tool guidance explaining persistent state, owner isolation, uncertain idle results, cleanup, and the preference for existing one-shot tools when interaction is unnecessary. It does not add a global system-prompt recommendation or mount PTY in shipped defaults; dedicated ACP and headless snapshot overlays exercise the opt-in composition.
|
||||
|
||||
### Deferred work
|
||||
|
||||
- Full-screen TUI support, named key sequences, BEL interruption, terminal resize tools, and alternate-screen snapshots require a separately proven model-facing contract.
|
||||
- Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited.
|
||||
- Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol.
|
||||
- Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work.
|
||||
- Windows/ConPTY support requires a backend with Windows-native process ownership and signaling semantics.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Replace `bash`, filesystem tools, or task tools with PTY.** Rejected. One-shot tools retain stronger validation, approval, sandbox, output-bound, and replay contracts. PTY is reserved for interactive state.
|
||||
|
||||
**Add persistent mode to `bash`.** Rejected. Returning on readiness rather than process exit, retaining a process tree across calls, and exposing interactive stdin create a different ownership and failure contract.
|
||||
|
||||
**Require native master-fd access from `node-pty`.** Rejected. Its public API exposes no master fd. The local backend instead derives foreground groups and descendants from supported OS process metadata and treats unreadable metadata as a detector miss.
|
||||
|
||||
**Signal every member of the root PID's POSIX session.** Rejected. `node-pty` may expose a helper PID whose session belongs to the launcher, so SID-wide teardown can signal unrelated harness or desktop processes. A PID-identity-fenced descendant tree is narrower and safe by construction.
|
||||
|
||||
**Publish `PtyIdleDetector` as a replaceable registry.** Rejected. Only the local backend needs these platform probes, while remote backends may receive readiness over their own protocol. Backend replacement already provides the necessary extension point.
|
||||
|
||||
**Add a PTY-specific `sleep` tool.** Rejected. `ctx.tasks` already owns bounded waiting, cancellation, completion notices, and model-facing collection. A second general wake mechanism would cross the agent-loop boundary and duplicate that contract.
|
||||
|
||||
**Include TUI sequences and BEL handling.** Rejected. The source prototype treats those paths as timing-sensitive and still records unresolved alternate-screen and interaction failures. Line-oriented PTY use proves the core value without making those unverified behaviors foundational.
|
||||
|
||||
**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current persistent front doors already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here.
|
||||
|
||||
## Verification
|
||||
|
||||
- Per-file coverage pins owner fencing, concurrent reservations, lifecycle cleanup, readiness tiers, sanitizer carry state, UTF-8 bounds, task integration, schemas, and render intents.
|
||||
- Linux process fixtures cover non-leader and non-main-thread stdin waits, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
|
||||
- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, signals, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
|
||||
- A Loader-driven `cordis.yml` test mounts the real three-package composition, while ACP and headless snapshots pin the six schemas, bounded results, error rendering, and terminal/generic cards through opt-in overlays.
|
||||
- Package contracts, the architecture map, core data structures, generated catalogs, and the website API describe the same shipped surface.
|
||||
- The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Persistent terminal state is available without weakening one-shot tools.** Shell and REPL state can survive tool calls, while `bash`, `read`, `write`, and `edit` retain their narrower validation, approval, and replay contracts.
|
||||
|
||||
**Idle below Linux Tier 1 is heuristic.** Output silence cannot distinguish a prompt from sleep or network I/O. The typed result preserves uncertainty, and bounded timeout plus task waiting and signaling keep control with the model.
|
||||
|
||||
**Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic.
|
||||
|
||||
**A daemonized descendant can leave the captured tree.** A process that reparents before teardown is no longer discoverable from the `node-pty` root. The implementation accepts that cleanup gap instead of risking SID-wide signals to unrelated processes.
|
||||
|
||||
**A shell can cause external side effects.** Session sandboxing and environment scrubbing reduce local exposure but do not undo pushes, API calls, or messages. Deployments that cannot tolerate those effects must omit PTY or add network policy.
|
||||
|
||||
**Process loss destroys terminal state.** In-process sessions do not survive a harness crash or restart, and raw scrollback is not durable. Important work must be committed to files or another durable system.
|
||||
|
||||
**`node-pty` is a native dependency.** Installation, supported Node versions, prebuild availability, and platform behavior require built-artifact smokes on every supported OS.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Agent Note: 持久化 PTY 会话
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-16-persistent-pty-sessions.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 可以运行前台与后台命令、编辑文件和委派工作,但无法跨工具调用延续一次交互式终端对话。每次 `bash` 前台运行都会启动一个新 shell,因此 shell 内的 cwd、导出变量、虚拟环境激活状态、函数、job control 状态和交互式子进程都会随本次调用结束。
|
||||
|
||||
这个缺口排除了状态驻留在终端而不是文件中的工作流,例如单步调试 `gdb`、在 Python 或 Node REPL 中探索、驱动 `ed` 这类行式编辑器,或者中断前台命令后回到原 shell。通用的 [`ctx.tasks`](../../../../packages/tasks/README.md) 运行时可以保留后台操作句柄和输出,但不提供交互式 stdin 或终端语义。
|
||||
|
||||
现有 `bash`、`read`、`write` 和 `edit` 工具仍是有界、可审计操作的可靠默认选项。PTY 是对确实需要终端状态的工作的补充功能,不说明这些工具有缺陷,更不意味着要移除它们。
|
||||
|
||||
## 决策
|
||||
|
||||
可选的 `packages/pty/` 功能家族提供由 agent 拥有、持久化且面向行式交互的 PTY 会话。它遵循仓库的 [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md),与现有命令和文件系统工具并存,并且不修改 `agent-loop`。
|
||||
|
||||
当前实现在 Linux 和 macOS 上支持交互式 shell 与行式 REPL。全屏终端应用、按键序列、BEL 触发的控制流、进程丢失后的会话恢复以及跨 agent 共享会话都明确推迟。
|
||||
|
||||
### 包拓扑
|
||||
|
||||
| 包 | 角色 | ctx key |
|
||||
|---|---|---|
|
||||
| `dsh-pty` | `PtyService`、branded `PtySessionId`、后端注册表、按 owner 隔离的会话契约和结果类型 | `ctx.pty` |
|
||||
| `dsh-pty-local` | 基于 [`node-pty`](https://github.com/microsoft/node-pty) 的本地后端、平台进程检查、有界终端缓冲、沙箱解析和进程树监管 | 在 `ctx.pty` 上注册后端 |
|
||||
| `dsh-tool-pty` | 6 个面向模型的工具、后台发送的 task 运行时集成、使用指引和 ACP render intent | 注册到 `ctx.tools` |
|
||||
|
||||
idle 检测属于后端行为,不是第二条公共 seam。远程或容器后端可能拥有完全不同于本地 `/proc` 检查的权威就绪信号;因此每个 `PtyBackend` 都返回统一的发送结果,同时在内部拥有自己的检测机制。
|
||||
|
||||
### agent 所有权与身份
|
||||
|
||||
`PtyService` 在进程内保存活会话,但每个会话都由工具执行上下文传入的确切 `Agent` 拥有。服务铸造不透明的 `PtySessionId`;模型可选填的 `name` 只是显示元数据,仅在该 owner 内唯一。所有操作都以 `sessionId` 为目标,`list`/`read`/`signal`/`kill` 会拒绝 owner 之外的调用方。
|
||||
|
||||
实现不提供插件加载期 auto-start 会话。`terminal_open` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。
|
||||
|
||||
agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。
|
||||
|
||||
### 安全与进程边界
|
||||
|
||||
注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-pty-local` 在 spawn 前应用两层保护:
|
||||
|
||||
- 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*`、`*SECRET*`、`*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。
|
||||
- 它要求 `ctx.sandbox` 和共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode,并只包装一次 shell argv;该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass。
|
||||
|
||||
沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。
|
||||
|
||||
实现只使用 `node-pty` 的公共功能:子进程 PID、`data` 与 `exit` 通知、`write`、`resize` 和 `kill`。它不假设能访问原生 master fd,也不从 TypeScript 调用 `waitpid`。平台进程检查器在 Linux 上通过 `/proc`、在 macOS 上通过 `ps` 推导前台进程组和父子进程身份。
|
||||
|
||||
### 6 个面向模型的工具
|
||||
|
||||
| 工具 | 用途 | 结果 |
|
||||
|---|---|---|
|
||||
| `terminal_open` | 从已注册的后端类型创建按 owner 隔离的会话 | `{ sessionId, name, type, motd }` |
|
||||
| `terminal_send` | 发送文本、可选提交 Enter,并等待就绪或注册一个后台任务 | 有界 viewport、等待状态和会话状态;后台模式还返回 `taskId` |
|
||||
| `terminal_read` | 从保留的 scrollback 读取一个有界页 | `{ text, totalLines, lineBegin, lineEnd, truncated }` |
|
||||
| `terminal_signal` | 向当前前台进程组发送一种允许的信号 | `{ delivered, targetPgid }` |
|
||||
| `terminal_close` | 关闭一个会话并等待进程树静默退出 | `{ killed }` |
|
||||
| `terminal_list` | 列出调用方的活会话 | 按 owner 隔离的会话摘要 |
|
||||
|
||||
`terminal_send({ sessionId, text, submit?, run_in_background? })` 将 `text` 视为 UTF-8 字节,并由工具实现在解析阶段把 `submit` 默认成 `true`。`submit` 为 true 时先写入文本,再写入平台 Enter 序列;为 false 时只写文本,使控制字符和 REPL 片段无需隐藏的内容启发式即可发送。
|
||||
|
||||
前台发送返回有界的渲染增量和两个独立事实:`waitReason`(`stdin_read | inferred_idle | timeout | session_exit`)与 `sessionStatus`(`running`,或携带退出码或信号的 `exited`)。`session_exit` 指 PTY 顶层 shell 进程退出,不指由 shell 消费状态的任意前台命令。timeout 从不意味着进程已经退出。
|
||||
|
||||
当 `run_in_background: true` 时,`dsh-tool-pty` 在 `ctx.tasks` 上注册进行中的发送,并立即返回 `taskId`。`task_output(wait: true)` 负责等待、读取增量输出并记录最终结果;`task_kill` 将取消转发为 `SIGINT`,只有 PTY 后端拥有的 teardown 路径可以升级信号。若 task 对外接口不存在,后台模式必须在写入输入前失败。设计不新增 PTY 专用的 `sleep` 工具或通用唤醒 seam。
|
||||
|
||||
`terminal_read` 从最新保留行向后分页。后端同时对保留的 scrollback 和完整返回值执行行数与 UTF-8 字节上限,因此单个超长行无法绕过限制。`truncated` 用于区分保留数据丢失与普通 viewport 增量。
|
||||
|
||||
`terminal_signal` 接受闭合集 `SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP`。后端在执行时解析终端前台进程组。当目标组是顶层 shell 时拒绝 `SIGKILL`,并指引调用方使用 `terminal_close`;进程组解析失败时操作直接失败,而不是向猜测的 PID 发送信号。
|
||||
|
||||
### 本地就绪检测
|
||||
|
||||
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,再执行 3 个有界 fallback 层级。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs` 和 `timeoutMs`。
|
||||
|
||||
在 Linux 上,检查器从 `/proc/<shellPid>/stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。
|
||||
|
||||
macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入并在 Linux 上完成 unit 覆盖率,同时由 macOS CI job 驱动真实 PTY 和进程表路径。
|
||||
|
||||
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。
|
||||
|
||||
`node-pty` data 通知进入同一个流式 decoder 和终端 parser。parser 的 carry 状态处理跨 chunk 的 UTF-8 与终端查询序列。当前实现只规范化行式输出并检测 alternate-screen 进入,不承诺正确操作全屏应用。
|
||||
|
||||
### 模型可见输出与持久性
|
||||
|
||||
现有持久化 `tool/call` 与 `tool/result` 事件是模型发送文本和返回给模型的渲染输出的真源。`terminal_open` 通过已记录的工具结果返回 MOTD;前台 `send`/`read`/`list`/`signal`/`close` 结果走同一路径记录。PTY 包不会把原始字节流重复写入自定义会话事件。
|
||||
|
||||
后台发送复用现有后台任务完成通知和 `task_output` 结果路径,因此进入后续模型请求的任何输出同样持久化。原始终端字节只作为有界的进程内状态存在,既不持久化也不可恢复。未来的 opt-in transcript sink 必须拥有独立的保留、凭证和隐私契约。
|
||||
|
||||
### 进程树 teardown
|
||||
|
||||
顶层 `node-pty` 子进程是所有权锚点。关闭时,后端先停止 callback,再按父 PID 以子进程优先顺序捕获该 PID 及其传递子进程、发送 `SIGTERM`、关闭 PTY 并等待静默,然后在可配置的 `disposeGraceMs` 后向已验证的存活者发送 `SIGKILL`,并等待它们离开进程表。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。
|
||||
|
||||
teardown 独立报告根进程退出与存活进程清理。它不会只因 shell 退出就声称成功;dispose 只有在已捕获的进程树成员全部消失后才完成,否则返回结构化清理失败并列出存活者。即使某个 close 失败,服务 dispose 仍会清空其后端、预留与 owner detacher 注册表。所有权绝不会扩大到根 PID 所属 POSIX 会话的全部成员。
|
||||
|
||||
### 组合与推行
|
||||
|
||||
示例组合保持 opt-in,并采用安全默认值:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
'@deepseek-ai/dsh-sandbox-local':
|
||||
'@deepseek-ai/dsh-sandbox-policy':
|
||||
config:
|
||||
mode: workspace-write
|
||||
workspaceRoot: .
|
||||
'@deepseek-ai/dsh-pty':
|
||||
'@deepseek-ai/dsh-pty-local':
|
||||
config:
|
||||
scrollbackLines: 10000
|
||||
scrollbackMaxBytes: 4194304
|
||||
maxReadBytes: 262144
|
||||
pollIntervalMs: 50
|
||||
exactProbeAfterMs: 150
|
||||
idleSilenceMs: 3000
|
||||
timeoutMs: 30000
|
||||
disposeGraceMs: 3000
|
||||
'@deepseek-ai/dsh-tool-pty':
|
||||
```
|
||||
|
||||
包提供简洁的工具指引,说明持久状态、owner 隔离、不确定的 idle 结果、清理,以及无需交互时优先使用现有一次性工具。它不增加全局 system prompt 推荐,也不在已发布的默认配置中挂载 PTY;专用 ACP 与 headless 快照 overlay 覆盖 opt-in 组合。
|
||||
|
||||
### 推迟的工作
|
||||
|
||||
- 全屏 TUI 支持、命名按键序列、BEL 中断、终端 resize 工具和 alternate-screen 快照需要另行验证面向模型的契约。
|
||||
- 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。
|
||||
- harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。
|
||||
- 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。
|
||||
- Windows/ConPTY 支持需要具备 Windows 原生进程所有权与信号语义的后端。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**用 PTY 替换 `bash`、文件系统工具或 task 工具。**拒绝。一次性工具拥有更强的校验、审批、沙箱、输出上限和回放契约。PTY 只服务交互式状态。
|
||||
|
||||
**给 `bash` 增加持久模式。**拒绝。按就绪而不是进程退出返回、跨调用保留进程树、暴露交互式 stdin 会形成不同的所有权和失败契约。
|
||||
|
||||
**要求从 `node-pty` 获取原生 master fd。**拒绝。它的公共 API 不暴露 master fd。本地后端改为从受支持的 OS 进程元数据推导前台组与子孙进程,并把不可读元数据视为 detector miss。
|
||||
|
||||
**向根 PID 所属 POSIX 会话的全部成员发送信号。**拒绝。`node-pty` 可能暴露属于启动器会话的 helper PID,因此按 SID 清理可能向无关的 harness 或桌面进程发送信号。带 PID 启动身份校验的子孙进程树范围更窄,其安全边界由结构保证。
|
||||
|
||||
**发布可替换注册表 `PtyIdleDetector`。**拒绝。只有本地后端需要这些平台 probe,远程后端可能通过自己的协议接收就绪状态。替换后端已经提供所需扩展点。
|
||||
|
||||
**新增 PTY 专用 `sleep` 工具。**拒绝。`ctx.tasks` 已经拥有有界等待、取消、完成通知和面向模型的收集。第二套通用唤醒机制会跨越 agent loop(智能体循环)边界并重复该契约。
|
||||
|
||||
**包含 TUI sequence 与 BEL 处理。**拒绝。源 prototype 将这些路径视为 timing-sensitive,且仍记录未解决的 alternate-screen 和交互失败。行式 PTY 已能证明核心价值,无需把未经验证的行为放进基础层。
|
||||
|
||||
**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前持久 front door 已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。
|
||||
|
||||
## 验证
|
||||
|
||||
- 每文件覆盖率固定 owner 隔离、并发预留、生命周期清理、就绪层级、sanitizer carry state、UTF-8 上限、task 集成、schema 和 render intent。
|
||||
- Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
|
||||
- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、信号、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默。
|
||||
- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合;ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果、错误渲染和 terminal/generic card。
|
||||
- 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。
|
||||
- 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。
|
||||
|
||||
## 后果
|
||||
|
||||
**无需削弱一次性工具即可获得持久终端状态。**Shell 与 REPL 状态可以跨工具调用保留,而 `bash`、`read`、`write` 和 `edit` 继续拥有更窄的校验、审批与回放契约。
|
||||
|
||||
**Linux Tier 1 之外的 idle 都是启发式结果。**输出静默无法区分 prompt、sleep 和网络 I/O。类型化结果保留不确定性,有界 timeout、task 等待与信号让模型仍能掌握控制权。
|
||||
|
||||
**持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何 prompt 都无法让状态持久化变成确定行为。
|
||||
|
||||
**daemonized 子进程可能离开捕获树。**在 teardown 前 reparent 的进程无法再从 `node-pty` 根进程发现。实现接受这个清理缺口,不冒险按 SID 向无关进程发送信号。
|
||||
|
||||
**Shell 可以造成外部副作用。**会话沙箱和环境清洗降低本地暴露,但无法撤销 push、API 调用或消息发送。无法容忍这些副作用的部署必须省略 PTY 或增加网络策略。
|
||||
|
||||
**进程丢失会销毁终端状态。**进程内会话无法跨 harness crash 或 restart 存活,原始 scrollback 也不持久化。重要工作必须提交到文件或其他持久系统。
|
||||
|
||||
**`node-pty` 是原生依赖。**安装、支持的 Node 版本、prebuild 可用性和平台行为都需要在每个支持 OS 上运行 built-artifact smoke。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.md: 3e2b1e751001020eccc9193438daff23dd42518a
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5f3632f43702e16ca9dec07c0bb8e42bf50499b7
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.md: ecfda138593fc2b98ac42929acc586b11e437ee2
|
||||
2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6b8cc63f7657672a6da542e2033d765b54bd4f07
|
||||
|
||||
@@ -22,7 +22,7 @@ The selected front door receives the exact generated or resumed `SessionId` used
|
||||
|
||||
The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle.
|
||||
|
||||
Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and pairs the selected model with its reasoning state; during a run, elapsed activity and the Escape interrupt hint replace that summary. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services.
|
||||
Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and shows the selected model; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services.
|
||||
|
||||
The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model pair per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README
|
||||
|
||||
TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。
|
||||
|
||||
agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并将选中模型及其推理状态组合显示;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。
|
||||
agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。
|
||||
|
||||
`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个 step 快照一次同一个提供方/模型字段组合,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-dsh-cli-personal-config.md: e349374a6bc7fc0137bf14836469aef8bae8d49d
|
||||
2026-07-20-dsh-cli-personal-config.zh.md: 88210dc386a245002de927950dab2852e40218ea
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: The dsh CLI and personal config overlays from the Harness home
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-dsh-cli-personal-config.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static, so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer.
|
||||
|
||||
## Decision
|
||||
|
||||
Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443):
|
||||
|
||||
**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** via the repo's tsx (with `--expose-internals` for the config's HMR entry), so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry.
|
||||
|
||||
**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim:
|
||||
|
||||
- `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`.
|
||||
- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics are exactly the committed overlay semantics (the Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config`, `insert` appends entries, an unmatched id warns and is skipped.
|
||||
- A missing file means no overlay; a present-but-unreadable, unparsable, or non-array file throws at boot (misconfiguration fails loud, never a silent skip).
|
||||
|
||||
The PTY smoke's launcher isolates `$DSH_HOME` to a per-test directory, exactly as it already isolates `DSH_AGENTS_HOME`, so a developer's real personal overlay cannot leak into fixtures; only the dsh CLI reads personal config, so no other test launcher needed changes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A standalone `bin/dsh` wrapper owning the `dsh` name.** Rejected after reading PR #443: that PR establishes `apps/cli` as the `dsh` CLI with subcommand dispatch (`web`, `-p`) and leaves the default slot unclaimed. Two competing `dsh` entrypoints would collide in `$PATH` and in product identity; claiming the default slot inside the same package shape confines the eventual merge conflict to the small dispatch chain.
|
||||
|
||||
**A pi-style typed settings file (`defaultProvider`/`defaultModel`/`providers`).** Rejected by the user in favor of patch semantics: the personal file is a cordis overlay over the shipped default config, not a second config vocabulary to own and translate.
|
||||
|
||||
**A personal full `cordis.yml` that includes the requested config.** Rejected: the personal file would have to name the leaf config's path, which varies per checkout; patches invert the dependency so the bin keeps choosing the tree and the personal layer only amends it.
|
||||
|
||||
**Deep-merging personal patches into entry configs.** Rejected: it would fork the patch semantics from the committed overlays and the vendored include; whole-config replacement is already the documented contract.
|
||||
|
||||
**Opt-in via env flag instead of presence.** Rejected: personal config that is off by default never gets used; presence plus explicit per-test isolation gives live runs the overlay and tests hermeticity.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `dsh` from any directory (and `pnpm run demo:tui`) boots the personal provider/model with zero repo changes; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip.
|
||||
- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings are the only diagnostics.
|
||||
- Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred.
|
||||
- `dsh-app-boot` depends on `js-yaml` (plus a load-only copy of the include's `!!js` YAML type) and, like `apps/cli`, on `@deepseek-ai/dsh-paths` for `resolveDshHome`.
|
||||
- When PR #443 lands, `apps/cli/src/bin.ts`'s dispatch chain and `apps/cli/package.json`'s dependency list conflict textually; both resolve as unions (their `web`/`-p` branches plus our default-TUI branch).
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/personal-config.spec.ts` pins `!!js` preservation and end-to-end interpolation through a booted tree, insert entries, the default directory resolving from `$DSH_HOME`, the absent/empty no-op paths, and the three fail-loud shapes (unreadable, unparsable, non-array). `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the dsh bin in a PTY three ways: default config with no overlay, a personal `.env` + `config.yaml` chain whose patched welcome renders in the banner, and an invalid personal file failing the boot loudly. The pre-existing smokes and snapshot suites pass on a machine whose real `~/.dsh` overlay would change the booted model — the isolation, not luck.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: dsh CLI 与来自 Harness home 的个人配置 overlay
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-dsh-cli-personal-config.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的,所以「条件组合使用 overlay」(AGENTS.md)——但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。
|
||||
|
||||
## Decision
|
||||
|
||||
两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐:
|
||||
|
||||
**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,用仓库的 tsx **从源码**运行该 bin(带 `--expose-internals`,供配置里的 HMR 配置项使用),因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。
|
||||
|
||||
**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:
|
||||
|
||||
- `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。
|
||||
- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与已提交 overlay 完全一致(Code Mode overlay 是模板):按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 记录警告并跳过。
|
||||
- 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。
|
||||
|
||||
PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**独立的 `bin/dsh` 包装脚本占有 `dsh` 这个名字。** 读过 PR #443 后否决:该 PR 把 `apps/cli` 确立为带子命令分发(`web`、`-p`)的 `dsh` CLI,并且默认位空缺。两个互相竞争的 `dsh` 入口会在 `$PATH` 和产品身份上冲突;在同一包形态内认领默认位,把最终的合并冲突限制在小小的分发链上。
|
||||
|
||||
**pi 风格的类型化设置文件(`defaultProvider`/`defaultModel`/`providers`)。** 用户否决,选择补丁语义:个人文件是叠加在随仓库提供的默认配置之上的 cordis overlay,而不是需要另行拥有和翻译的第二套配置词汇。
|
||||
|
||||
**个人完整 `cordis.yml` 去 include 请求的配置。** 否决:个人文件将不得不写死叶子配置的路径,而该路径随 checkout 变化;补丁反转了依赖方向,bin 仍然选择配置树,个人层只做修正。
|
||||
|
||||
**把个人补丁深合并进配置项配置。** 否决:会使补丁语义与已提交 overlay 和 vendor 的 include 分叉;整个 `config` 替换已是成文契约。
|
||||
|
||||
**用环境变量开关代替存在性判断。** 否决:默认关闭的个人配置永远不会被用起来;存在即生效加上每个测试的显式隔离,让实际运行获得 overlay、测试获得封闭性。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`)即可零仓库改动地使用个人提供方/模型;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。
|
||||
- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;loader 的「配置项未找到/名称不匹配」警告是仅有的诊断。
|
||||
- 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。
|
||||
- `dsh-app-boot` 依赖 `js-yaml`(外加一份只用于加载的 include `!!js` YAML 类型副本),并与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。
|
||||
- PR #443 落地时,`apps/cli/src/bin.ts` 的分发链与 `apps/cli/package.json` 的依赖列表会产生文本冲突;两者都按并集解决(他们的 `web`/`-p` 分支加上我们的默认 TUI 分支)。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/app-boot/tests/personal-config.spec.ts` 固定 `!!js` 的保留与经真实启动树的端到端插值、insert 配置项、默认目录从 `$DSH_HOME` 解析、缺失/为空的无操作路径,以及三种响亮失败形态(不可读、不可解析、非数组)。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里以三种方式启动 dsh bin:无 overlay 的默认配置、个人 `.env` + `config.yaml` 链条(打补丁的欢迎语渲染进横幅)、以及无效个人文件导致的响亮启动失败。既有冒烟与快照套件在一台真实 `~/.dsh` overlay 会改变启动模型的机器上通过——靠隔离,不靠运气。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-tui-startup-slogans.md: a2a22baafddd08145cec0d03b65ee56b2f8114b1
|
||||
2026-07-20-tui-startup-slogans.zh.md: 58fa5790f315845f27b810d62658bd79428b519b
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Startup slogans replace the configured TUI welcome line
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-tui-startup-slogans.zh.md)
|
||||
|
||||
> **Superseded** for the slogan/animation half by the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md): the slogan bank and typewriter reveal shipped, read as weird in use, and were replaced by a subtitle-free banner with a whole-banner sweep. The removal of the configured demo welcome and the animation-lifecycle groundwork (start after `ui.start()`, clear through `detachListeners`) stand.
|
||||
|
||||
## Problem
|
||||
|
||||
The TUI header subtitle came from a `welcome` config the demo leaf set to "TUI agent ready. Give it a coding task." — instructional filler that told a returning user nothing, restated what the product is on every boot, and had a hardcoded twin (`'ready.'`) as the schema default in two packages. The product wanted a startup moment with some character instead of a static banner caption.
|
||||
|
||||
## Decision
|
||||
|
||||
- `examples/tui-agent/cordis.yml` no longer configures `welcome`; the config key stays for deployments and fixtures that need a fixed, deterministic subtitle (the Code Mode overlay and every snapshot/scripted fixture keep theirs).
|
||||
- When `welcome` is unset, `dsh-tui` picks one member of an exported `STARTUP_SLOGANS` bank per boot (`pickStartupSlogan`, injectable random source) and reveals it with a typewriter animation: one character per 40 ms frame, a `▌` block cursor trailing until complete. The reveal starts only after `ui.start()` succeeds and its interval is cleared on dispose alongside the other listeners.
|
||||
- The slogan bank is presentation copy, deliberately not config: deployments that want controlled wording already have `welcome`. Slogans are ASCII-only by contract because the reveal slices per character.
|
||||
- `dsh-tui-demo` forwards `welcome` only when configured instead of defaulting it, so the app no longer decides the TUI's idle subtitle.
|
||||
- The keyless PTY boot scenario now waits for the reveal cursor (`▌` — the only source of that glyph in an empty transcript) instead of the removed welcome text.
|
||||
|
||||
The same change restores `packages/ui/tui/src/index.ts` to 100 % per-file coverage, which the color-scheme merge had broken on the integration branch: the editor border-color reassignment inside `applyColorScheme` was dead (the `setStatus` call right after re-derives it) and is removed, and the color-scheme query's `.then`/`.catch` arrows became named, tested handlers (`applyReportedScheme`, `ignoreSchemeQueryFailure` — the latter pinned by a test whose terminal throws on the DSR query write).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A fixed cooler slogan.** Rejected: one string re-read on every boot decays into wallpaper exactly like the line it replaces; a small rotating bank keeps the moment alive at no complexity cost.
|
||||
|
||||
**Making the bank and reveal speed configurable.** Rejected: that is two new knobs for presentation copy; `welcome` is already the escape hatch for deployments with an opinion, and the no-hardcoded-tunables rule targets deployment-varying behavior, not brand copy.
|
||||
|
||||
**Animating in `HeaderComponent` itself.** Rejected: the component would need a TUI handle and its own lifecycle; the chat already owns a render loop, timers, and a disposal path, so the reveal lives beside the other `createTuiChat` effects and `detachListeners` clears it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Boot output is no longer byte-deterministic when `welcome` is unset (random slogan, timed frames). Every recorded or snapshot surface pins `welcome` explicitly, so no snapshot changed; the PTY smoke anchors on the reveal cursor and the session-id line instead.
|
||||
- The `welcome` schema default disappeared from both `dsh-tui` and `dsh-tui-demo`; a direct caller passing no welcome now gets a slogan, not `'ready.'`.
|
||||
- Adding a slogan is a one-line bank edit; tests assert membership, not specific text.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins deterministic bank selection with an injected random source, the reveal (a bank member fully rendered, cursor frames observed), the configured-welcome path rendering verbatim with no cursor, and dispose stopping a mid-reveal animation. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real tree in a PTY and waits on the reveal cursor. Verified live in tmux (mid-reveal frame `no map below▌` then the full slogan).
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 启动 slogan 取代配置化的 TUI 欢迎语
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-tui-startup-slogans.md) | 中文
|
||||
|
||||
> **已被取代**:slogan/动画的那一半由[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md)取代:slogan 库和打字机动画上线后实际使用中显得怪异,已替换为无副标题的横幅加整体扫入。移除示例配置中欢迎语的决定与动画生命周期基础设施(`ui.start()` 后启动、经 `detachListeners` 清除)保持不变。
|
||||
|
||||
## Problem
|
||||
|
||||
TUI 头部副标题来自一个 `welcome` 配置,示例叶子配置把它设为 "TUI agent ready. Give it a coding task."——一句说明书式的填充语,对老用户毫无信息量,每次启动都在复述产品是什么,而且它还有一个硬编码的孪生兄弟(`'ready.'`)作为两个包里的 schema 默认值。产品需要的是一个有性格的启动时刻,而不是一条静态横幅说明。
|
||||
|
||||
## Decision
|
||||
|
||||
- `examples/tui-agent/cordis.yml` 不再配置 `welcome`;该配置键保留给需要固定、确定性副标题的部署与 fixture(Code Mode overlay 和所有快照/脚本化 fixture 都保留各自的欢迎语)。
|
||||
- `welcome` 未设置时,`dsh-tui` 每次启动从导出的 `STARTUP_SLOGANS` 库里挑选一条(`pickStartupSlogan`,随机源可注入),并以打字机动画逐字显示:每帧 40 ms 一个字符,完成前尾随一个 `▌` 块状光标。动画只在 `ui.start()` 成功后启动,其定时器与其他监听器一起在 dispose 时清除。
|
||||
- slogan 库是展示文案,刻意不做成配置:想控制措辞的部署已经有 `welcome` 这个出口。按契约 slogan 只含 ASCII,因为逐字显示按字符切片。
|
||||
- `dsh-tui-demo` 只在配置了 `welcome` 时才转发它,不再填默认值,应用不再替 TUI 决定空闲副标题。
|
||||
- 无 key 的 PTY 启动场景改为等待逐字显示的光标(`▌`——空 transcript 里该字形的唯一来源),不再等待已删除的欢迎文本。
|
||||
|
||||
同一变更把 `packages/ui/tui/src/index.ts` 恢复到 100% 的单文件覆盖率(颜色方案合并曾在集成分支上破坏它):`applyColorScheme` 里对编辑器边框颜色的重新赋值是死代码(紧随其后的 `setStatus` 调用会重新推导它),已删除;颜色方案查询的 `.then`/`.catch` 箭头函数改为具名、有测试的处理器(`applyReportedScheme`、`ignoreSchemeQueryFailure`——后者由一个让终端在 DSR 查询写入时抛错的测试固定)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**换一条更酷的固定 slogan。** 否决:一条每次启动都重读的字符串会和它取代的那行一样退化成墙纸;一个小的轮换库以零复杂度代价让这个时刻保持新鲜。
|
||||
|
||||
**把 slogan 库和显示速度做成配置。** 否决:那是为展示文案新增两个旋钮;对措辞有主张的部署已经有 `welcome` 这个出口,而「插件里不许硬编码可调参数」规则针对的是随部署变化的行为,不是品牌文案。
|
||||
|
||||
**在 `HeaderComponent` 内部做动画。** 否决:组件将需要持有 TUI 句柄和自己的生命周期;聊天层已经拥有渲染循环、定时器和释放路径,所以逐字显示与 `createTuiChat` 的其他资源放在一起,由 `detachListeners` 清除。
|
||||
|
||||
## Consequences
|
||||
|
||||
- `welcome` 未设置时启动输出不再字节级确定(随机 slogan、定时帧)。所有录制或快照表面都显式固定 `welcome`,因此没有快照变化;PTY 冒烟测试改为锚定逐字显示光标和会话 id 行。
|
||||
- `welcome` 的 schema 默认值从 `dsh-tui` 和 `dsh-tui-demo` 中消失;不传 welcome 的直接调用方现在得到的是 slogan,而不是 `'ready.'`。
|
||||
- 新增一条 slogan 只需在库里加一行;测试断言成员归属,不断言具体文本。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定以下行为:注入随机源后的确定性选取、逐字显示(库中某条完整渲染、观察到光标帧)、配置了 welcome 时逐字动画不启动且原文渲染、以及 dispose 停止进行中的动画。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里启动真实配置树并等待显示光标。已在 tmux 中实机验证(中途帧 `no map below▌`,随后是完整 slogan)。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-cross-session-references.md: bfa015b24cda6c8651829b6a7f0800326da5b502
|
||||
2026-07-21-cross-session-references.zh.md: e8e99124f7e2ccfe9fbe97323c17143372017562
|
||||
@@ -0,0 +1,60 @@
|
||||
# Agent Note: Cross-session references
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-cross-session-references.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
TUI and ACP users need to bring relevant work from another conversation into one new message without resuming, forking, or granting the source transcript authority over the current session. The harness already exposes exact session enumeration and raw event inspection, but every host independently parsing logs would duplicate compaction folding, provenance filtering, size limits, error behavior, and persistence. Encoding host markup directly into the agent message contract would also bind the core loop to one UI syntax.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferences`. Hosts normalize their protocol into `SessionReferenceInput[]`, call `prepare()` before enqueue, and pass the returned contexts through the generic `SendOptions.contexts` boundary. Core agent packages know only that one queued message may carry frozen `HookContext[]`; they do not parse session URIs or read another log.
|
||||
|
||||
`dsh-session:<base64url(JSON.stringify(sessionId))>` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)` and ACP uses standard `resource_link`; text-only clients may use the same inline mention. Explicit Markdown mentions and resource links reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text.
|
||||
|
||||
The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live-preferred corpus observation, folds it with the session package's canonical surface algorithm, and returns a detached header, capture seq, and current nodes. FTS is not a dependency: v1 discovery filters only id and cwd, and future title/body search can replace the candidate layer without changing reference identity or preparation.
|
||||
|
||||
## Snapshot and projection
|
||||
|
||||
Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `send()` or `steer()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session.
|
||||
|
||||
Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compact`. That marker is part of the compaction capability contract rather than a backend package name. When a source prompt already contains baked prefix context, projection reads only its model-hidden display content, so referencing that target later does not recursively propagate an earlier snapshot. Projection excludes shadowed pre-compaction nodes, tools and results, reasoning, injected context, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery.
|
||||
|
||||
One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags. The `## My request:` text is a routing cue rather than the trust boundary: referenced data may spell those words inside a JSON string, but it cannot forge the closing `</referenced-sessions>` tag or escape the data region. The same serializer drives each source's independent byte accounting. The context declares `prompt-prefix` placement, so AgentLoop persists one `user/message` or `steering/message` containing the snapshot, `## My request:` delimiter, and effective direct prompt. Its model-hidden envelope retains the direct display content and source/retention metadata. Target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type or a separate user-role context message.
|
||||
|
||||
## Message ownership
|
||||
|
||||
`send()` and `steer()` snapshot content, resolved source, and contexts together as one deeply frozen lossless-JSON inbox record. Synthetic `inject()` accepts source and model-hidden metadata but not attached contexts, which belong to inbox messages. A claimed ordinary message exposes its attached contexts as the default `agent/prompt-submit` additional contexts; a block writes neither user message nor contexts. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream content and contexts unless it intentionally replaces them. After admission, absent or `separate` placement writes an independent `context/message`, while `prompt-prefix` placement bakes context and the effective request into one prompt event. Drained steering bypasses `agent/prompt-submit` but applies the same placement split. Late steering retains the same record when converted to queued input, while cancellation, disposal, and terminal discard drop message and contexts together. `agent/queued` reports the frozen contexts so the observation event describes the complete retained item.
|
||||
|
||||
This preserves host driving semantics: TUI decides `send()` versus `steer()` from the agent state after preparation, so only its queued path dispatches UserPromptSubmit hooks; ACP continues to call `send()` once per `session/prompt`. Reference preparation is not a new steering protocol and does not create a turn by itself.
|
||||
|
||||
## Host adapters
|
||||
|
||||
TUI combines session candidates with the existing `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal.
|
||||
|
||||
ACP detects direct slash commands from ordinary prompt flattening before extracting `dsh-session:` resource links and canonical inline mentions, so URI-shaped command arguments remain opaque while ordinary resource-link rendering is preserved. Standard `session/list` exposes each loadable session's folded title and, when references are mounted, a canonical URI under `_meta["deepseek-harness/sessionReference"]`; a client can use `title ?? sessionId` as the resource-link name. A valid reference without the optional service returns a capability-unavailable RPC error, and preparation failure occurs before the in-flight turn slot and agent send. A preparation-specific abort owner makes `session/cancel` and bridge teardown stop pending reads. Picker UI remains an ACP client responsibility because ACP does not define a cross-session mention menu.
|
||||
|
||||
## Budget and retention
|
||||
|
||||
Each of at most three references is independently capped at 65,536 UTF-8 bytes by default. Retention preserves current compact checkpoints and the newest conversation unit before dropping older non-checkpoint messages. An oversized retained text uses `dsh-retention` head/tail slicing and records exact omitted bytes; if one source's fixed serialized fields cannot fit its cap, the whole preparation fails rather than emitting a partial context.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only.
|
||||
- **Put mention syntax in `Agent.send()`** — rejected because it would make the core protocol parse TUI/ACP presentation and prevent typed non-text hosts from sharing the semantic layer.
|
||||
- **Implement references inside TUI and ACP separately** — rejected because projection, security warning, retention, and persistence would drift across hosts.
|
||||
- **Place a separate user-role context message beside the prompt** — rejected because two adjacent user messages weaken the prompt's deictic binding: in `@foo what does this session discuss?`, the model may resolve “this session” as the current conversation instead of the referenced snapshot.
|
||||
- **Bake the prefix host-side before `send()`** — rejected because `agent/prompt-submit` must inspect and rewrite only the direct prompt. The effective prompt and attached contexts meet only after admission in AgentLoop, which can apply an `allow.content` rewrite consistently to both combined model content and `envelope.displayContent`; earlier host assembly would expose snapshot bytes to the hook or let those two views diverge.
|
||||
- **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history.
|
||||
- **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity.
|
||||
- **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state.
|
||||
|
||||
## Verification
|
||||
|
||||
Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, title-aware ACP session listing, ordinary ACP resource links, opaque ACP command arguments, and compact TUI/ACP replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string.
|
||||
|
||||
## Consequences
|
||||
|
||||
The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. Standard TUI/ACP demo bundles mount it explicitly and expose its count and per-source byte limits in their own config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Agent Note: 跨会话引用
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-cross-session-references.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
TUI 与 ACP(Agent Client Protocol)用户需要把另一场对话中的相关工作带入一条新消息,但不恢复、不 fork,也不让源 transcript(文本记录)对当前会话拥有权威性。harness 已经提供准确的会话枚举与原始事件检查,但若每个宿主都独立解析日志,就会重复实现压缩(compaction)折叠、来源过滤、大小限制、错误行为和持久化。把宿主标记直接编码进 agent(智能体)消息契约,还会让核心循环绑定某一种 UI 语法。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferences` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,在入队前调用 `prepare()`,再通过通用的 `SendOptions.contexts` 边界传递返回的上下文。核心 agent 包只知道一条排队消息可以携带已冻结的 `HookContext[]`;它们既不解析会话 URI,也不读取其他日志。
|
||||
|
||||
`dsh-session:<base64url(JSON.stringify(sessionId))>` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中,ACP 使用标准 `resource_link`;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记与资源链接会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。
|
||||
|
||||
该服务使用 `ctx.sessionQuery.readSurface(sessionId)`:它优先从实时会话加载一次语料观察结果,使用会话包的规范表层算法执行折叠,并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是功能依赖:v1 的候选发现只按 id 和 cwd 过滤;未来的标题或正文搜索可以替换候选层,而无需改变引用标识或准备过程。
|
||||
|
||||
## 快照与投影
|
||||
|
||||
准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `send()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。
|
||||
|
||||
投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compact` 导出的规范来源标记的检查点用户消息。该标记属于压缩功能契约的一部分,而非某个后端包名称。当源提示词已包含合并写入的前缀上下文时,投影只读取其模型不可见的显示内容,因此后续引用该目标不会递归传播先前的快照。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、注入的上下文、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。
|
||||
|
||||
系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签。`## My request:` 文本只是路由提示,不是信任边界:被引用数据可以在 JSON 字符串中包含这些词,但无法伪造闭合的 `</referenced-sessions>` 标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。该上下文声明 `prompt-prefix` 放置方式,因此 AgentLoop 会持久化一条 `user/message` 或 `steering/message`,其中包含快照、`## My request:` 分隔符和最终生效的直接提示词。其模型不可见封套保留直接显示内容以及来源与保留元数据。因此,目标回放无需新增事件类型或单独的用户角色上下文消息,也能满足「模型可见/日志可重建」不变量。
|
||||
|
||||
## 消息所有权
|
||||
|
||||
`send()` 与 `steer()` 会把内容、解析后的来源和上下文一起快照为一条深度冻结、无损 JSON 的收件箱记录。合成的 `inject()` 接受来源和模型不可见的元数据,但不接受附加上下文,因为上下文属于收件箱消息。普通消息被认领后,其附带的上下文会作为 `agent/prompt-submit` 的默认附加上下文公开;提示词被阻止时,系统既不写入用户消息,也不写入上下文。waterfall(瀑布式事件)返回的 allow 结果具有最终权威性,因此监听器包装 `next()` 时会保留下游内容与上下文,除非它有意替换这些值。消息被接纳后,未指定放置方式或指定为 `separate` 时会写入独立的 `context/message`;指定为 `prompt-prefix` 时则会把上下文与最终生效的请求合并写入同一个提示词事件。排空 steering 消息时会绕过 `agent/prompt-submit`,但采用相同的放置方式分流。延迟到达的 steering 转换为排队输入时保留同一条记录;取消、dispose(资源释放)和到达终止态后的丢弃则会同时丢弃消息与上下文。`agent/queued` 会报告已冻结的上下文,使观察事件能够描述完整的保留项。
|
||||
|
||||
这保留了宿主的驱动语义:TUI 在准备完成后根据 agent 状态决定调用 `send()` 还是 `steer()`,因此只有它的排队路径才会分派 UserPromptSubmit 钩子;ACP 则继续调用 `send()`,每个 `session/prompt` 调用一次。引用准备过程不是新的 steering 协议,本身也不会创建轮次。
|
||||
|
||||
## 宿主适配器
|
||||
|
||||
TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。
|
||||
|
||||
ACP 先从普通提示词扁平化结果中检测直接斜杠命令,再提取 `dsh-session:` 资源链接和规范的行内提及标记,因此形如 URI 的命令参数保持不透明,同时保留普通资源链接的渲染方式。标准 `session/list` 会公开每个可加载会话折叠后的标题;挂载会话引用功能时,还会在 `_meta["deepseek-harness/sessionReference"]` 下公开规范 URI。客户端可以使用 `title ?? sessionId` 作为资源链接名称。若引用有效但可选服务未挂载,系统会返回「功能不可用」RPC 错误;准备失败会发生在占用进行中轮次槽位并调用 agent send 之前。引用准备过程单独拥有中止控制权,因此 `session/cancel` 和桥接释放都能停止待处理的读取。选择器 UI 仍由 ACP 客户端负责,因为 ACP 未定义跨会话提及菜单。
|
||||
|
||||
## 预算与保留策略
|
||||
|
||||
最多三个引用中的每一个默认独立限制在 65,536 个 UTF-8 字节以内,不设置完整提示词的总预算。保留策略会优先保留当前压缩检查点和最新的对话单元,再丢弃较旧的非检查点消息。若保留文本过大,系统使用 `dsh-retention` 进行首尾切片并记录准确的省略字节数;若某个源的固定序列化字段无法装入其上限,整个准备过程会失败,不会输出部分上下文。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。
|
||||
- **把提及标记语法放入 `Agent.send()`**:不予采纳,因为这会迫使核心协议解析 TUI/ACP 的表现层,并阻止带类型的非文本宿主复用同一语义层。
|
||||
- **在 TUI 和 ACP 中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。
|
||||
- **在提示词旁放置单独的用户角色上下文消息**:不予采纳,因为相邻的两条用户消息会削弱提示词的指示语绑定:在 `@foo what does this session discuss?` 中,模型可能把「this session」解析为当前对话,而不是被引用的快照。
|
||||
- **在调用 `send()` 前由宿主合并前缀**:不予采纳,因为 `agent/prompt-submit` 必须只检查和改写直接提示词。最终生效的提示词与附加上下文只有在 AgentLoop 接纳后才汇合;此时 AgentLoop 可以把 `allow.content` 改写一致应用于合并后的模型内容和 `envelope.displayContent`。若由宿主更早组装,就会向该钩子暴露快照字节,或使这两个视图发生偏离。
|
||||
- **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。
|
||||
- **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。
|
||||
- **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。
|
||||
|
||||
## 验证
|
||||
|
||||
单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失、包含标题信息的 ACP 会话列表、普通 ACP 资源链接、不透明的 ACP 命令参数,以及精简的 TUI/ACP 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。
|
||||
|
||||
## 后果
|
||||
|
||||
新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 TUI/ACP 演示组合包会显式挂载它,并在各自的配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-dsh-system-prompt-source-path.md: b54d01488fd7c0b49e06200c93af2b056c9fd00b
|
||||
2026-07-21-dsh-system-prompt-source-path.zh.md: 208e3dce072f63c280999e15276dce62ff4e5c43
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: dsh tells the agent where its own source lives
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-dsh-system-prompt-source-path.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The `dsh` CLI is the self-referential surface: its `cordis` toolset lets the agent inspect and modify the very harness runtime it runs in. But the agent had no way to learn where that source lives on disk. `dsh` is normally symlinked onto PATH and launched from an arbitrary working directory — the project under work — so neither the cwd nor `argv` reliably points at the harness checkout. Without the path, "read your own source" is guesswork.
|
||||
|
||||
## Decision
|
||||
|
||||
The `dsh` launcher (`apps/cli/src/tui.ts`) computes the harness checkout root from its own module URL — `fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — so it resolves to the real source location however `dsh` is launched (a PATH symlink, an arbitrary cwd). After `boot()` settles the tree, the launcher calls the new `addHarnessSourceSection(ctx, sourceRoot)` helper from `dsh-app-boot`, which registers a global `harness:source` prompt section reading `Your own source code is the checkout at <path>; you can read it there to learn how dsh works and how to extend it.` The section orders at `-99`, just after the harness identity opener (`-100`) and before the deployment persona (`0`).
|
||||
|
||||
The testable logic lives in `dsh-app-boot`, not in `apps/cli`, because `apps/*` are not coverage-gated and `packages/*` are. Resolving the optional `systemPrompt` service, registering the section, and returning the disposer belong where per-file 100% coverage applies; the launcher keeps only the thin glue — compute the path, call the helper — covered by the CLI's PTY e2e. When the booted tree has no `systemPrompt` service the helper is a no-op returning `undefined`.
|
||||
|
||||
## Scope
|
||||
|
||||
Only the `dsh` CLI adds this. The demo bins (`dsh-tui-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs.
|
||||
|
||||
## HMR
|
||||
|
||||
The section is registered against the booted `systemPrompt` service's own fiber (through `ctx.get('systemPrompt')`), so a dev HMR reload of the system-prompt plugin drops it until the next boot. Production HMR watches the config, not the built lib, so this is a dev-only wrinkle and acceptable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Register the section inside the system-prompt service constructor.** It would then appear in every deployment, not just the self-referential CLI, and the source root would have to be threaded through config to reach the constructor. The path is a launcher fact, so the launcher owns injecting it.
|
||||
|
||||
**Keep the whole thing in `apps/cli/src/tui.ts`.** Apps are not coverage-gated, so the registration and absent-service branches would ship untested. Extracting the tested helper into `dsh-app-boot` keeps the gate meaningful; the launcher glue is exercised by the CLI's keyless PTY smoke.
|
||||
|
||||
**Add a cordis.yml config field for the path.** The path is not a deployment choice — it is mechanically the launcher's own location. A config field invites a stale hand-entered path and adds a knob with no legitimate variation.
|
||||
|
||||
**Resolve from `process.cwd()` or `process.argv[1]`.** The cwd is the user's project, and a PATH symlink makes `argv[1]` the symlink path; `import.meta.url` is the only handle on the real source location.
|
||||
|
||||
## Consequences
|
||||
|
||||
The agent's system prompt now names its own checkout, so the `cordis` toolset can read and edit harness source with no discovery step. `dsh-app-boot` gains a type-only dependency on `dsh-system-prompt` (peer + dev, matching the acp package's side-effect type import) for the `ctx.get('systemPrompt')` declaration merge; there is no runtime dependency. The section is model-visible text, pinned verbatim in an app-boot unit test and asserted end to end through the CLI's keyless PTY smoke — which boots `dsh` against the scripted config, runs a turn, and reads the path back out of the persisted `request/header` system prompt. The line sits before per-request content, so it does not perturb the KV cache across turns.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: dsh 告知 agent 其自身源码所在位置
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-dsh-system-prompt-source-path.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh` CLI 是自我引用的接口:其 `cordis` 工具包让 agent(智能体)得以查看并修改它自身运行其上的 harness(智能体框架)运行时。但 agent 此前无从得知这份源码在磁盘上的位置。`dsh` 通常以符号链接的形式挂到 PATH 上,并从任意工作目录(正在处理的项目)启动,因此无论是 cwd 还是 `argv` 都无法可靠地指向 harness 检出目录。缺了这个路径,"读取你自己的源码"便只能靠猜。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh` 启动器(`apps/cli/src/tui.ts`)从它自身的模块 URL 计算 harness 检出根目录——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——因此无论 `dsh` 以何种方式启动(PATH 符号链接、任意 cwd),它都能解析到真实的源码位置。在 `boot()` 使插件树就位之后,启动器调用来自 `dsh-app-boot` 的新辅助函数 `addHarnessSourceSection(ctx, sourceRoot)`,它注册一个全局 `harness:source` 提示词段,内容为 `Your own source code is the checkout at <path>; you can read it there to learn how dsh works and how to extend it.`。该段的 order 为 `-99`,恰在 harness 身份开场(`-100`)之后、部署 persona(`0`)之前。
|
||||
|
||||
可测试的逻辑放在 `dsh-app-boot` 而非 `apps/cli` 中,因为 `apps/*` 不受覆盖率门禁约束,而 `packages/*` 受约束。解析可选的 `systemPrompt` 服务、注册该段、返回 dispose(资源释放)器,这些都属于按文件 100% 覆盖率生效的地方;启动器只保留那层薄薄的黏合——计算路径、调用辅助函数——由 CLI 的 PTY e2e 覆盖。当就位的插件树没有 `systemPrompt` 服务时,该辅助函数是一个返回 `undefined` 的空操作。
|
||||
|
||||
## Scope
|
||||
|
||||
只有 `dsh` CLI 会加入这一段。demo bin(`dsh-tui-demo`、`dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。
|
||||
|
||||
## HMR
|
||||
|
||||
该段是针对就位后的 `systemPrompt` 服务自身的 fiber 注册的(通过 `ctx.get('systemPrompt')`),因此对 system-prompt 插件做一次开发态 HMR(热模块替换)重载会丢弃它,直到下一次引导为止。生产环境的 HMR 监视的是配置而非构建产物 lib,所以这只是一个仅限开发态的小瑕疵,可以接受。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 system-prompt 服务的构造函数内注册该段。** 那样它会出现在每一个部署中,而不只是自我引用的 CLI,而且源码根目录还得穿过配置才能到达构造函数。这个路径是启动器的事实,所以由启动器负责注入它。
|
||||
|
||||
**把整件事都留在 `apps/cli/src/tui.ts` 里。** apps 不受覆盖率门禁约束,因此注册逻辑与服务缺失分支会以未受测的形式发布。把受测的辅助函数抽取到 `dsh-app-boot` 让门禁保持有效;启动器的黏合部分由 CLI 的无密钥 PTY 冒烟测试演练。
|
||||
|
||||
**为该路径新增一个 cordis.yml 配置键。** 这个路径不是一项部署选择——它在机制上就是启动器自身的位置。配置键会招致手工填入的路径变陈旧,并新增一个没有合理变化空间的旋钮。
|
||||
|
||||
**从 `process.cwd()` 或 `process.argv[1]` 解析。** cwd 是用户的项目,而 PATH 符号链接会使 `argv[1]` 成为符号链接自身的路径;`import.meta.url` 是唯一能抓住真实源码位置的把手。
|
||||
|
||||
## Consequences
|
||||
|
||||
agent 的系统提示词现在会写明它自己的检出目录,因此 `cordis` 工具包无需一个发现步骤就能读取并编辑 harness 源码。`dsh-app-boot` 为 `ctx.get('systemPrompt')` 的声明合并新增了一个对 `dsh-system-prompt` 的仅类型依赖(peer dependency(对等依赖)+ dev,与 acp 包的副作用型类型 import 模式一致);不存在运行时依赖。该段是模型可见文本,在 app-boot 单元测试中逐字锁定,并通过 CLI 的无密钥 PTY 冒烟测试端到端断言——该测试以脚本化配置引导 `dsh`、运行一个轮次,再从持久化的 `request/header` 系统提示词中把路径读回来。这一行位于按请求变化的内容之前,所以它不会在多个轮次间扰动 KV Cache。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-follow-instruction-symlinks.md: 49b02c38fb49241f5941dc3431c43f031fb7193e
|
||||
2026-07-21-follow-instruction-symlinks.zh.md: ba47325dde30cea899b2e038221f841bdfa2f1c6
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Follow symlinked instruction files
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-follow-instruction-symlinks.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [workspace-context plugin](2026-06-24-workspace-context.md) probed each instruction candidate with `ctx.fs.lstat` before resolving, rejecting any final-component symlink so a repository-owned link could not point instruction loading at content outside the workspace. That no-follow invariant blocked a deliberate, supported setup: a user who symlinks `$DSH_HOME/AGENTS.md` — or a project `AGENTS.md` — to a canonical instruction file kept elsewhere, sharing one house-style file across tools and homes, saw the link silently ignored. It also forced content dedup to treat the ubiquitous `CLAUDE.md → AGENTS.md` mirror as a special skipped case rather than an ordinary duplicate. The repository owner asked to follow symlinked instruction files unconditionally across every scope, accepting the residual trust-boundary risk recorded below.
|
||||
|
||||
## Decision
|
||||
|
||||
Instruction discovery no longer inspects the final component with `lstat`. Every candidate — the user-global `$DSH_HOME/AGENTS.md`, each base candidate, and each local-overlay candidate — is resolved and its resolved target is stat-ed, at baseline composition and at each `tools/post-execute` reconciliation alike. A symlink whose target is a regular file loads that target's content; a resolved non-file target (including a link to a directory) is a confirmed absence that removes the scope like a missing file; a `resolve` or `stat` exception is classified as temporarily unavailable and never removes an already-loaded scope. `nodeStatFile` calls `stat` (host path) and `fsStatFile` calls `resolve` then `stat` (provider path); neither calls `lstat`.
|
||||
|
||||
A followed symlink is an ordinary file for every downstream step. It participates in per-directory content dedup ([load-all + dedup note](2026-07-21-instruction-load-all-dedup.md)), so a `CLAUDE.md` that symlinks its sibling `AGENTS.md` now resolves to identical content and collapses like any byte-identical real duplicate instead of being skipped as a special case.
|
||||
|
||||
### Trust boundary and residual risk
|
||||
|
||||
Following repository-owned links crosses the plugin's trust boundary: a cloned, untrusted repository can carry an `AGENTS.md` whose symlink target is any file the process can read, surfacing off-tree content as workspace guidance. That content enters only as a lower-authority user-role prefix framed by the system-reminder pattern; it never overrides system, developer, or direct user instructions, and it is treated as data, not authority. The mitigating boundary is the filesystem layer, not this plugin: confine `ctx.fs` with the `dsh-fs-policy` gate or an OS sandbox ([cross-family fs sandbox](2026-07-14-cross-family-fs-sandbox.md)) when a deployment loads untrusted repositories. This is an explicit, owner-accepted trade-off, not an oversight.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the `lstat` no-follow invariant.** Rejected by the repository owner: it blocks the supported symlink-to-canonical-file setup and forces the symlink-mirror case to be a skipped special case rather than a plain duplicate. The read-authority boundary it approximated belongs in the filesystem policy and sandbox layer, which contains the same risk more precisely.
|
||||
|
||||
**Follow only the user-global `$DSH_HOME` candidate and keep no-follow for project files.** Rejected: the owner asked for uniform behavior across every scope, and a split rule is harder to reason about than one consistently applied policy plus a documented boundary. A project the user chose to open is not meaningfully more trusted than the user's own home.
|
||||
|
||||
**Follow symlinks but reject targets that resolve outside the project root.** Rejected: it reintroduces a partial trust boundary in the wrong layer — path geometry rather than read authority — breaks the legitimate `$DSH_HOME`-to-elsewhere case, and duplicates containment the filesystem policy gate already owns.
|
||||
|
||||
## Consequences
|
||||
|
||||
A symlinked instruction file is now loaded and rendered like its target, enabling shared canonical instruction files across tools and homes, and the `CLAUDE.md → AGENTS.md` mirror deduplicates through content instead of being skipped. The plugin no longer depends on `ctx.fs.lstat` for instruction loading; a resolved non-file is a confirmed absence and only a provider exception is temporarily unavailable. The trust boundary moves out of this plugin into the filesystem policy and sandbox layers, which must confine `ctx.fs` when a deployment loads untrusted repositories. The [workspace-context note](2026-06-24-workspace-context.md) and the package README carry the same follow behavior and residual-risk statement.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 跟随符号链接指向的指令文件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-follow-instruction-symlinks.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[workspace-context 插件](2026-06-24-workspace-context.md)在解析前用 `ctx.fs.lstat` 探测每个指令候选,拒绝任何末段的符号链接,从而使仓库自有的链接无法把指令加载指向工作区之外的内容。这条「不跟随」不变式挡住了一个有意为之、且受支持的配置:用户若把 `$DSH_HOME/AGENTS.md`(或某个项目的 `AGENTS.md`)符号链接到别处保存的一个规范指令文件,以便在多个工具与多个 home 之间共享同一份规范文件,就会看到该链接被悄悄忽略。它还迫使内容去重把无处不在的 `CLAUDE.md → AGENTS.md` 镜像当作一个被跳过的特例来处理,而非一个普通的重复文件。仓库所有者要求在每个 scope 上无条件跟随符号链接指向的指令文件,并接受下文记录的残余信任边界风险。
|
||||
|
||||
## 决策
|
||||
|
||||
指令发现不再用 `lstat` 检查末段。每个候选(用户全局的 `$DSH_HOME/AGENTS.md`、每个基础候选,以及每个本地覆盖候选)都会被解析,并对其解析后的目标做 stat,基线组合时与每一轮 `tools/post-execute` 协调时一视同仁。一个目标为常规文件的符号链接会加载该目标的内容;一个解析后的非文件目标(包括指向目录的链接)是被确认的缺失,会像缺失文件一样移除该 scope;一个 `resolve` 或 `stat` 异常被归类为暂时不可用,且从不移除已加载的 scope。`nodeStatFile` 调用 `stat`(宿主路径),`fsStatFile` 先 `resolve` 再 `stat`(提供方路径);两者都不调用 `lstat`。
|
||||
|
||||
一个被跟随的符号链接对下游每一步都是普通文件。它参与按目录的内容去重([加载全部并去重 note](2026-07-21-instruction-load-all-dedup.md)),因此一个符号链接指向其同级 `AGENTS.md` 的 `CLAUDE.md` 现在会解析到相同内容,并像任何逐字节相同的真实副本一样被合并,而不再作为特例被跳过。
|
||||
|
||||
### 信任边界与残余风险
|
||||
|
||||
跟随仓库自有的链接会越过插件的信任边界:一个被克隆的、不受信任的仓库可以携带一个 `AGENTS.md`,其符号链接目标是该进程能读取的任意文件,从而把树外内容作为工作区指导暴露出来。该内容仅作为一条被 system-reminder 模式框定的、较低权限的 user 角色前缀进入;它绝不覆盖 system、developer 或用户的直接指令,并被当作数据而非权限对待。起缓解作用的边界在文件系统层,而非本插件:在部署加载不受信任的仓库时,用 `dsh-fs-policy` 门或一个操作系统沙箱([跨家族 fs 沙箱](2026-07-14-cross-family-fs-sandbox.md))约束 `ctx.fs`。这是一个明确的、由所有者接受的取舍,而非疏漏。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**保留 `lstat` 的「不跟随」不变式。** 被仓库所有者否决:它挡住了受支持的「符号链接到规范文件」配置,并迫使符号链接镜像场景成为一个被跳过的特例而非普通重复。它所近似的读取权限边界属于文件系统策略与沙箱层,那里能更精确地遏制同一风险。
|
||||
|
||||
**只跟随用户全局的 `$DSH_HOME` 候选,项目文件保持不跟随。** 否决:所有者要求在每个 scope 上行为一致,而一条分裂的规则比一条一致应用的策略加一条有文档记录的边界更难推理。用户选择打开的项目并不比用户自己的 home 更值得信任。
|
||||
|
||||
**跟随符号链接,但拒绝解析到项目根之外的目标。** 否决:这会在错误的层(路径几何而非读取权限)重新引入一条局部的信任边界,破坏合理的「`$DSH_HOME` 指向别处」场景,并重复文件系统策略门已经拥有的遏制。
|
||||
|
||||
## 影响
|
||||
|
||||
一个符号链接指向的指令文件现在会像其目标一样被加载和渲染,从而支持在多个工具与多个 home 之间共享规范指令文件,而 `CLAUDE.md → AGENTS.md` 镜像会通过内容去重而非被跳过。指令加载不再依赖 `ctx.fs.lstat`;一个解析后的非文件是被确认的缺失,只有提供方异常才是暂时不可用。信任边界从本插件移出,进入文件系统策略与沙箱层。当部署加载不受信任的仓库时,它们必须约束 `ctx.fs`。[workspace-context note](2026-06-24-workspace-context.md) 与包(package) README 承载相同的跟随行为与残余风险声明。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-instruction-load-all-dedup.md: 4e895b0b7f14600adeaf8742e68eab088e3d6d24
|
||||
2026-07-21-instruction-load-all-dedup.zh.md: e27c2d2ad6e6fd291dc3344aab6ff96806fe405f
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Load all instruction candidates with per-directory dedup
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-instruction-load-all-dedup.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [workspace-context plugin](2026-06-24-workspace-context.md) resolved one winning file per candidate list per directory: the first existing name in `instructionFileCandidates` won the base slot, and the [local overlay](2026-07-21-local-instruction-overlay.md) added one more winner. But `AGENTS.md` and `CLAUDE.md` routinely coexist in the same directory. In most repositories one is a symlink to the other, so they carry identical content; in repositories mid-migration they are two distinct real files that have drifted apart. First-wins silently dropped the non-winning committed file, so a directory that legitimately carried two distinct instruction files only ever surfaced one — and which one depended on candidate order, not on content. The request was to read both and deduplicate only when they are effectively the same file.
|
||||
|
||||
## Decision
|
||||
|
||||
Every existing candidate in each list loads — the base list first, then the local list — in configured order. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in that order, and the kept file's original bytes are rendered. Dedup is per-directory rather than global, and symmetric across the base and local lists. Trimming before comparison tolerates a trailing newline or indentation difference between a file and its near-copy while still rendering the survivor verbatim — the "extra safe" comparison the request asked for.
|
||||
|
||||
Symlinks now flow through this uniformly. Instruction discovery resolves each candidate and stats its target instead of rejecting a final-component symlink, so a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to identical content and collapses here like any byte-identical real duplicate. Content dedup therefore renders the common symlink-mirror once through the same path as a real copy. The [follow-symlinks note](2026-07-21-follow-instruction-symlinks.md) owns that reversal and its residual trust-boundary risk.
|
||||
|
||||
## Scope keys become per-candidate
|
||||
|
||||
Each `(directory, candidateName)` pair is now its own logical scope, encoded `directory\u0000candidateName` with a NUL separator that cannot occur in a real path. `candidateScopeKey` / `decodeScopeKey` own the encoding, and `probeScopeInstruction` decodes the candidate name to read exactly that file. This replaces the tier-sentinel scope key the overlay note introduced: a directory no longer has a "base scope" and a "local scope" but one scope per candidate name, so `AGENTS.md` and `CLAUDE.md` in one directory are independent scopes that reconcile separately.
|
||||
|
||||
Because a scope now names one fixed file, the previous "candidate switch within a scope" — an `AGENTS.md` scope that fell through to `CLAUDE.md` and recorded the old name in `previousPath` — can no longer occur. `previousPath` was removed from the change record, the serialized `context/message` metadata, and the render text; a change is now either `set`, a same-file `replace`, or a `remove`. Removing one candidate emits a `remove` for that candidate's own scope, leaving a distinct sibling as an independent scope.
|
||||
|
||||
Dedup is enforced during reconciliation, not only at baseline composition. Each reconciliation pass rebuilds a per-directory set of kept trimmed-content digests in candidate order, so an unchanged file is removed when an earlier candidate converges on its content, and a newly duplicate sibling is dropped or removed. The version cache stores a `trimmedDigest` beside the full content digest so the fast path can re-evaluate duplication without re-reading content.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep first-wins per candidate list.** Rejected: it silently drops a directory's second committed instruction file and makes the survivor depend on candidate order rather than on whether the files actually differ, which is exactly the surprise the request set out to remove.
|
||||
|
||||
**Global, cross-directory dedup.** Rejected: identical boilerplate under two different directories is legitimately in scope for each, and the deeper file must still surface for work under the deeper directory. Collapsing across directories would hide instructions the model should see.
|
||||
|
||||
**Compare raw bytes without trimming.** Rejected: an editor that adds a trailing newline, or a copy that reflows indentation, would defeat dedup for files that are the same in substance. Trimming before comparison is the tolerant key the request asked for, and the survivor still renders its original bytes.
|
||||
|
||||
**Follow symlinks so a mirror deduplicates through content.** Rejected for this change to preserve the no-follow invariant, then adopted separately: the [follow-symlinks note](2026-07-21-follow-instruction-symlinks.md) reverses that invariant, after which a symlinked mirror is resolved and deduplicated through content exactly like a real duplicate.
|
||||
|
||||
## Consequences
|
||||
|
||||
A directory with two distinct real instruction files now surfaces both; a directory whose second file merely mirrors the first still renders once, and the ubiquitous symlink case is unchanged. The visible behavior difference is confined to transition repositories that carry two distinct real files. The scope-key shape changed from a tier sentinel to a per-candidate key and `previousPath` disappeared from the durable change metadata; `dsh-session` keeps no compatibility promise for older sessions, so both are free changes. The version cache row grew a `trimmedDigest` field, and reconciliation now compares trimmed content per directory, so an unchanged file can be removed by a sibling's convergence — a transition the [state model](2026-06-24-workspace-context.md) previously could not produce.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 加载全部指令候选并按目录去重
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-instruction-load-all-dedup.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[workspace-context 插件](2026-06-24-workspace-context.md)在每个目录中为每个候选列表只解析出一个胜出文件:`instructionFileCandidates` 中第一个存在的名字赢得基础槽位,[本地覆盖层](2026-07-21-local-instruction-overlay.md)再追加一个胜出者。但 `AGENTS.md` 与 `CLAUDE.md` 经常共处同一目录。在多数仓库里其中一个是另一个的符号链接,因此内容完全相同;在迁移中的仓库里它们则是两个已经产生分歧的、彼此独立的真实文件。先到先得会悄悄丢弃未胜出的已提交文件,于是一个合理地携带两个不同指令文件的目录最终只暴露其中一个——而暴露哪一个取决于候选顺序,而非内容。需求是把两者都读取,仅在它们实质上是同一文件时才去重。
|
||||
|
||||
## 决策
|
||||
|
||||
每个列表中每个存在的候选都会被加载——先基础列表,再本地列表——按配置顺序进行。在同一目录内,内容在去除首尾空白后逐字节相同的候选会合并到该顺序中最靠前的候选,并渲染被保留文件的原始字节。去重是按目录进行的,而非全局,并且在基础列表与本地列表之间对称。比较前先做去空白处理,可以容忍某文件与其近似副本之间的末尾换行或缩进差异,同时仍逐字节渲染保留下来的文件——这正是需求所要求的「格外稳妥」的比较。
|
||||
|
||||
符号链接现在会统一经此流转。指令发现会解析每个候选并对其目标做 stat,而非拒绝末段的符号链接,因此一个符号链接指向其同级 `AGENTS.md` 的 `CLAUDE.md` 会解析到相同内容,并在此像任何逐字节相同的真实副本一样被合并。因此内容去重会通过与真实副本相同的路径把常见的符号链接镜像只渲染一次。[跟随符号链接 note](2026-07-21-follow-instruction-symlinks.md) 拥有该反转决策及其残余的信任边界风险。
|
||||
|
||||
## scope 键改为按候选划分
|
||||
|
||||
现在每个 `(directory, candidateName)` 对都是各自独立的逻辑 scope,编码为 `directory\u0000candidateName`,其中 NUL 分隔符在真实路径中不可能出现。`candidateScopeKey` / `decodeScopeKey` 负责这套编码,`probeScopeInstruction` 则解码候选名以精确读取该文件。这取代了覆盖层 note 引入的层级哨兵 scope 键:一个目录不再有「基础 scope」和「本地 scope」,而是每个候选名一个 scope,因此同一目录中的 `AGENTS.md` 与 `CLAUDE.md` 是各自独立协调的 scope。
|
||||
|
||||
由于一个 scope 现在只对应一个固定文件,此前的「同一 scope 内的候选切换」——即一个 `AGENTS.md` scope 回退到 `CLAUDE.md` 并把旧名字记录在 `previousPath` 中——不再可能发生。`previousPath` 已从变更记录、序列化的 `context/message` 元数据以及渲染文本中移除;一次变更现在要么是 `set`、要么是同一文件的 `replace`、要么是 `remove`。移除某个候选会为该候选自己的 scope 发出一个 `remove`,而把不同的同级文件留作独立的 scope。
|
||||
|
||||
去重在协调过程中强制执行,而不仅仅在基线组合时。每一轮协调都会按候选顺序重建一个按目录的「已保留去空白摘要」集合,因此当更靠前的候选收敛到某文件的内容时,一个未变更的文件也会被移除,而新出现的重复同级文件会被丢弃或移除。版本缓存在完整内容摘要之外还存储一个 `trimmedDigest`,使快速路径无需重新读取内容即可重新判定是否重复。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**每个候选列表保持先到先得。** 否决:这会悄悄丢弃一个目录的第二个已提交指令文件,并使胜出者取决于候选顺序、而非文件是否真的不同,而这恰恰是需求要消除的意外。
|
||||
|
||||
**全局的、跨目录的去重。** 否决:两个不同目录下相同的样板内容对各自而言都合理地在作用域内,而更深层的文件对于该更深目录下的工作仍必须暴露。跨目录合并会隐藏模型本应看到的指令。
|
||||
|
||||
**不做去空白、直接比较原始字节。** 否决:一个添加末尾换行的编辑器,或一个重排缩进的副本,都会让实质相同的文件无法去重。比较前去空白正是需求所要求的宽容键,而保留下来的文件仍渲染其原始字节。
|
||||
|
||||
**跟随符号链接,从而让镜像通过内容去重。** 为本次改动否决以保留「不跟随」不变式,随后另行采纳:[跟随符号链接 note](2026-07-21-follow-instruction-symlinks.md) 反转了该不变式,此后符号链接镜像会被解析,并像真实副本一样通过内容去重。
|
||||
|
||||
## 影响
|
||||
|
||||
一个携带两个不同真实指令文件的目录现在会把两者都暴露;一个第二个文件仅仅是镜像的目录仍只渲染一次,而无处不在的符号链接场景保持不变。可见的行为差异被限定在携带两个不同真实文件的迁移期仓库中。scope 键的形态从层级哨兵改为按候选划分,`previousPath` 也从持久的变更元数据中消失;`dsh-session` 对旧会话不作兼容承诺,因此两者都是无成本的改动。版本缓存行新增了一个 `trimmedDigest` 字段,协调过程现在按目录比较去空白后的内容,因此一个未变更的文件可以被同级文件的收敛所移除——这是[状态模型](2026-06-24-workspace-context.md)此前无法产生的转换。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-local-instruction-overlay.md: 3c7b2141b0515b5e667be4add6ad765e26c88cd8
|
||||
2026-07-21-local-instruction-overlay.zh.md: 0fd45cfcdaf6db1ea6cb0746c8d8cfb3e86c76d7
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Default local instruction overlay
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-local-instruction-overlay.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Personal, git-ignored guidance (`AGENTS.local.md` / `CLAUDE.local.md`) is a Claude Code convention for per-developer overrides that are deliberately not committed. The [workspace-context plugin](2026-06-24-workspace-context.md) loaded only one candidate per directory, so a `.local.` name could only be reached by adding it to `instructionFileCandidates`, where — because a directory has one winner — it would *shadow* the committed base file instead of supplementing it. That inverts the additive "base plus personal overlay" model the names evoke, and it was off by default.
|
||||
|
||||
## Decision
|
||||
|
||||
The plugin loads a second, independent candidate list per project directory. `localInstructionFileCandidates` defaults to `['AGENTS.local.md', 'CLAUDE.local.md']` and is resolved with the same same-directory validation as `instructionFileCandidates`. In every project directory from the root to the session cwd, the plugin loads the base candidates and then, additively, the local candidates; the local files are ordered after the base files so their guidance takes precedence within the byte budget. Both lists load in full under [per-directory content dedup](2026-07-21-instruction-load-all-dedup.md). An empty `localInstructionFileCandidates` disables the overlay.
|
||||
|
||||
The default lives in the plugin `Config` schema rather than a product `cordis.yml`, so every embedder (TUI, ACP, headless) reads `.local.` files consistently and a deployment overrides or disables the behavior in one place. This is symmetric with the plugin-owned `instructionFileCandidates` default.
|
||||
|
||||
The fixed user-global `$DSH_HOME/AGENTS.md` has no local overlay and stays base-only.
|
||||
|
||||
## Independent scopes per candidate
|
||||
|
||||
The base and local candidates in one directory must stay independent across baseline freezing, the pending window, the version cache, and reconciliation, so a change to one never suppresses the other. Each `(directory, candidateName)` pair is its own scope key — see [per-candidate scope keys](2026-07-21-instruction-load-all-dedup.md), which replaced the earlier base/local tier sentinel. Discovery iterates the base list and then the local list in each project directory, `reconcileInstructionContext` enumerates every configured candidate per directory, and `probeScopeInstruction` decodes the candidate name to read exactly that file. The model-facing prompt derives its human directory label from the file display path, so the scope key never reaches the model.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Higher-priority first-wins (`.local.` loaded instead of the base file).** Rejected: a personal overlay that replaces the committed file drops shared project guidance whenever the overlay exists, which is the opposite of the additive Claude Code model.
|
||||
|
||||
**Keep it opt-in through `instructionFileCandidates`.** Rejected: one directory has a single winner, so a `.local.` name added to that list shadows the base file rather than supplementing it. The packages guidance to keep opt-ins out of shipped defaults is outweighed here by strong prior art and the user-facing expectation that `.local.` files are always read.
|
||||
|
||||
**Default at the product `cordis.yml` level instead of the plugin schema.** Rejected: it would enable `.local.` only for whichever front door remembered to opt in, splitting behavior across TUI/ACP/headless and duplicating a value that belongs beside the existing candidate default.
|
||||
|
||||
**Reuse the bare directory as the scope key for base and local files.** Rejected: base and local files in one directory would collide in every scope-keyed map, so a change to one would suppress or overwrite the other. A distinct scope key per candidate keeps them independent without widening the persisted metadata shape.
|
||||
|
||||
**Extend the overlay to the user-global scope.** Deferred: `$DSH_HOME` is a single fixed `AGENTS.md` with no committed base to supplement, so it stays base-only until a concrete need appears.
|
||||
|
||||
## Consequences
|
||||
|
||||
`.local.` guidance is read by default across all products with no per-deployment configuration, matching neighboring tools. Each project directory can contribute a durable scope per existing candidate rather than one, so dynamic discovery, edits, and removals reconcile the base and local files independently. The scope key is now [per-candidate](2026-07-21-instruction-load-all-dedup.md); `dsh-session` keeps no compatibility promise for older sessions, so this is a free change. The user-global scope remains base-only, recorded as a Known Limitation in the package README.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 默认的本地指令覆盖层
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-local-instruction-overlay.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
个人的、被 git 忽略的指导文件(`AGENTS.local.md` / `CLAUDE.local.md`)是 Claude Code 的一项约定,用于存放刻意不提交、每位开发者各自的覆盖内容。[workspace-context 插件](2026-06-24-workspace-context.md)每个目录只加载一个候选,因此只有把某个 `.local.` 名字加进 `instructionFileCandidates` 才能读到它;而由于一个目录只有一个胜出者,这样做只会让它*遮蔽*已提交的基础文件,而不是补充它。这与这些名字所暗示的「基础文件加个人覆盖层」的叠加模型正好相反,而且它默认是关闭的。
|
||||
|
||||
## 决策
|
||||
|
||||
插件为每个项目目录额外加载第二个独立的候选列表。`localInstructionFileCandidates` 默认为 `['AGENTS.local.md', 'CLAUDE.local.md']`,并与 `instructionFileCandidates` 采用相同的同目录校验来解析。在从项目根到会话 cwd 的每个项目目录中,插件先加载基础候选,然后叠加加载本地候选;本地文件排在基础文件之后,因此在字节预算之内其内容优先级更高。两个列表都会在[按目录内容去重](2026-07-21-instruction-load-all-dedup.md)之下完整加载。将 `localInstructionFileCandidates` 置空即可关闭该覆盖层。
|
||||
|
||||
该默认值定义在插件的 `Config` schema 中,而非某个产品的 `cordis.yml` 里,因此每个嵌入方(TUI、ACP、headless)读取 `.local.` 文件的行为一致,部署方也可以在一处覆盖或关闭该行为。这与插件自身持有的 `instructionFileCandidates` 默认值保持对称。
|
||||
|
||||
固定的用户全局文件 `$DSH_HOME/AGENTS.md` 没有本地覆盖层,始终只有基础文件。
|
||||
|
||||
## 每个候选各自独立的 scope
|
||||
|
||||
同一目录下的基础候选与本地候选,在基线冻结、待定窗口、版本缓存和协调过程中都必须彼此独立,因此对其中一个的改动绝不能抑制另一个。现在每个 `(directory, candidateName)` 对都是各自独立的 scope 键——参见[按候选划分的 scope 键](2026-07-21-instruction-load-all-dedup.md),它取代了此前基础/本地的层级哨兵。发现过程在每个项目目录中先遍历基础列表、再遍历本地列表,`reconcileInstructionContext` 为每个目录枚举每个配置的候选,`probeScopeInstruction` 则解码候选名以精确读取该文件。面向模型的提示词从文件的展示路径推导出供人阅读的目录标签,因此 scope 键永远不会到达模型。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**更高优先级的先到先得(加载 `.local.` 而非基础文件)。** 否决:一个会替换已提交文件的个人覆盖层,会在覆盖层存在时丢弃共享的项目指导,这与 Claude Code 的叠加模型正好相反。
|
||||
|
||||
**通过 `instructionFileCandidates` 保持按需开启。** 否决:一个目录只有一个胜出者,因此加进该列表的 `.local.` 名字会遮蔽基础文件,而非补充它。packages 指引要求把按需开启项排除在出厂默认之外,但此处强有力的现有实践、以及用户对 `.local.` 文件总会被读取的预期,压过了这一考量。
|
||||
|
||||
**在产品 `cordis.yml` 层面设默认,而非在插件 schema 中。** 否决:这样只会为记得开启的那个前门启用 `.local.`,从而在 TUI/ACP/headless 之间割裂行为,并重复一个本应与既有候选默认值放在一起的取值。
|
||||
|
||||
**两个层级复用原始目录作为 scope 键。** 否决:同一目录下的基础文件与本地文件会在每个以 scope 为键的映射中冲突,于是对其中一个的改动会抑制或覆盖另一个。为每个候选设置各自独立的 scope 键让两者保持独立,且无需扩展持久化的元数据结构。
|
||||
|
||||
**将覆盖层扩展到用户全局 scope。** 暂缓:`$DSH_HOME` 是单个固定的 `AGENTS.md`,没有可供补充的已提交基础文件,因此在出现具体需求前始终只有基础文件。
|
||||
|
||||
## 影响
|
||||
|
||||
`.local.` 指导在所有产品中默认被读取,无需按部署单独配置,与邻近工具保持一致。每个项目目录可以为每个存在的候选贡献一个持久 scope 而非仅一个,因此动态发现、编辑和移除会分别独立地协调基础文件与本地文件。scope 键现在[按候选划分](2026-07-21-instruction-load-all-dedup.md);`dsh-session` 对旧会话不作兼容承诺,因此这是一次无成本的改动。用户全局 scope 仍然只有基础文件,这一点作为 Known Limitation 记录在包 README 中。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-auto-pane-title.md: 069fd33a8874d9ad3d4472dd13f5130b2df65f08
|
||||
2026-07-21-tui-auto-pane-title.zh.md: 580f36b2563e21231a22cab3f0c1689c6f3e8d9d
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Auto-titled terminal from the first message
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-auto-pane-title.zh.md)
|
||||
|
||||
> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events.
|
||||
|
||||
> **Superseded** for the default and the resume behavior by the [auto-title default-on Agent Note](2026-07-21-tui-auto-title-default-on.md): `autoTitle` now defaults on, and a resumed session re-derives its title from the stored first message instead of keeping the static one. The OSC 0 path, the one-shot latch, the model-summary shape, the fire-and-forget call, and every failure fallback below stand.
|
||||
|
||||
## Problem
|
||||
|
||||
The TUI's terminal title is a single static string (`title`, default `DeepSeek Harness`) shared by every session. A user who runs one agent per tmux pane or terminal tab sees the same label on all of them, so panes are indistinguishable at a glance and the tab bar carries no signal about what each session is doing.
|
||||
|
||||
## Decision
|
||||
|
||||
- `TuiConfig` gains an `autoTitle` boolean (default `false`). When it is on, the TUI issues one background model call after the first user message of a fresh session and replaces the terminal title with a short, model-generated label; the static `title` is the pre-title and the fallback.
|
||||
- The label is a model summary, not a truncation of the prompt. The request carries a fixed task instruction (summarize the request as a short title of two to five lowercase words, no punctuation) plus the user's first message and no tools; the TUI takes the first non-empty line of the reply and caps it at 40 characters (39 plus an ellipsis).
|
||||
- The title is set through `runtime.terminal.setTitle`, the same OSC 0 path the static `title` already uses. No new terminal-control surface is introduced, and pi-tui keeps ownership of terminal writes.
|
||||
- The call is fire-and-forget and one-shot per session. A `titleSettled` latch guards it: with `autoTitle` off it is pre-settled and never runs; on a resumed session whose first `user/message` is already logged it is pre-settled so the static title stands; a whitespace-only first message is skipped without consuming the slot. Any failure, an empty reply, a missing `llm` service, or a missing agent provider/model leaves the static title untouched. A dedicated `AbortController` cancels an in-flight request on shutdown.
|
||||
- The title call reaches `ctx.llm.stream` directly rather than through `agent.send`, so it never appends to the session or transcript and cannot perturb the agent loop.
|
||||
- The feature defaults off and is enabled only in the interactive product config (`examples/tui-agent/cordis.yml`) and the scripted PTY fixture. Enabling it in the shared `dsh-tui-demo` schema default would fire an extra model call in keyless replay and boot scenarios that send no user message.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Truncate the first user message instead of a model title.** Rejected: the user chose a short model-made label; a truncated raw prompt is noisy, often begins with boilerplate, and rarely reads as a title.
|
||||
|
||||
**Rename the window (OSC 2) or the tmux window.** Rejected: OSC 0 sets only `pane_title`, so it labels the pane without renaming or leaking into the user's window title; the user confirmed OSC is the right lever.
|
||||
|
||||
**Default the feature on.** Rejected: enabling it in the shared demo schema perturbs keyless replay and boot snapshots and spends a model call on every fresh session; opt-in per deployment keeps the default surface inert.
|
||||
|
||||
**Fold this into the log-backed session-title work (PR #451).** Rejected: that change is session metadata persisted to the log; this is a terminal label with no persistence. Keeping them independent leaves each self-contained and avoids a shared dependency.
|
||||
|
||||
**Block the first turn until the title resolves.** Rejected: awaiting the title before sending the user's message adds latency to the actual request; fire-and-forget makes the rename invisible to the turn.
|
||||
|
||||
## Consequences
|
||||
|
||||
- When enabled, a fresh session spends one extra, tool-less model call with a single short user message and a few output tokens; off by default, it costs nothing.
|
||||
- Because the title call stamps `sessionId`, it shares the session's `llm-replay` cursor: enabling `autoTitle` in a replay-backed snapshot scenario would consume a recorded script entry. This is why the default is off and the scripted PTY fixture answers the call with a tool-branching adapter rather than replay.
|
||||
- `packages/ui/tui/tests/tui.spec.ts` pins the behavior with a mock `llm` adapter: a generated title replaces the static one, over-long output is truncated with an ellipsis, a whitespace-only first message keeps the one-shot slot, empty or failing replies leave the title, a resumed session never fires, and the feature-off / no-service / missing-provider / missing-model paths keep the static title. A shutdown test asserts the in-flight request is aborted.
|
||||
- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` proves the real Loader-booted path: the scripted adapter answers the tool-less title call with a fixed string, and the conversation scenario asserts the OSC 0 sequence reaches the PTY. Boot scenarios send no user message, so they never fire the call.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: 从首条消息自动命名终端
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-auto-pane-title.md) | 中文
|
||||
|
||||
> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。
|
||||
|
||||
> **已被取代**(就默认值与恢复行为而言),见[自动标题默认开启 Agent Note](2026-07-21-tui-auto-title-default-on.md):`autoTitle` 现默认开启,恢复会话会从已存储的首条消息重新推导标题,而非保留静态标题。下文的 OSC 0 路径、一次性门闩、模型概括形态、发出后不等待其返回的调用,以及每一条失败兜底,均仍然成立。
|
||||
|
||||
## Problem
|
||||
|
||||
TUI 的终端标题是一个所有会话共用的静态字符串(`title`,默认 `DeepSeek Harness`)。在 tmux 每个窗格或每个终端标签页各跑一个 agent(智能体)的用户看来,它们的标签全都一样,因此窗格一眼看去无从区分,标签栏也不携带任何关于各会话正在做什么的信号。
|
||||
|
||||
## Decision
|
||||
|
||||
- `TuiConfig` 新增布尔字段 `autoTitle`(默认 `false`)。开启后,TUI 会在全新会话的首条用户消息之后发起一次后台模型调用,并用一个简短的、模型生成的标签替换终端标题;静态 `title` 是替换前的初值,也是兜底。
|
||||
- 该标签是模型概括,而非对提示词的截断。请求携带一段固定的任务指令(将该请求概括为两到五个小写单词、不含标点的简短标题)加上用户的首条消息,且不带工具;TUI 取回复的首个非空行并截断到 40 个字符(39 个字符加一个省略号)。
|
||||
- 标题通过 `runtime.terminal.setTitle` 设置——静态 `title` 已经在用的同一条 OSC 0 路径。不引入任何新的终端控制面,终端写入仍归 pi-tui 所有。
|
||||
- 该调用发出后不等待其返回,且每会话仅一次。一个 `titleSettled` 门闩守护它:`autoTitle` 关闭时它预先置为已结算、从不运行;在首条 `user/message` 已入日志的恢复会话中它预先结算,因此静态标题得以保留;仅含空白的首条消息被跳过且不消耗名额。任何失败、空回复、缺少 `llm` 服务、或缺少 agent 的 `provider` 或 `model`,都会让静态标题保持不动。一个专用的 `AbortController` 在关闭时取消尚在进行的请求。
|
||||
- 标题调用直接抵达 `ctx.llm.stream`,而非经由 `agent.send`,因此它从不追加进会话或 transcript(文本记录),也无法扰动 agent loop(智能体循环)。
|
||||
- 该功能默认关闭,仅在交互式产品配置(`examples/tui-agent/cordis.yml`)与脚本化 PTY fixture(测试前置数据)中开启。若在共享的 `dsh-tui-demo` schema 默认值里开启,会在不发送任何用户消息的无密钥回放与启动场景中多发一次模型调用。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**截断首条用户消息,而非用模型生成标题。** 否决:用户选择的是简短的、模型制作的标签;截断后的原始提示词嘈杂、常以样板文字开头,且很少读起来像标题。
|
||||
|
||||
**重命名窗口(OSC 2)或 tmux 窗口。** 否决:OSC 0 只设置 `pane_title`,因此它标记窗格而不重命名、也不泄漏进用户的窗口标题;用户确认 OSC 是正确的手段。
|
||||
|
||||
**让该功能默认开启。** 否决:在共享的 demo schema 里开启会扰动无密钥回放与启动快照,并在每个全新会话上花掉一次模型调用;按部署选择性开启可让默认面保持惰性。
|
||||
|
||||
**并入日志支撑的会话标题工作(PR #451)。** 否决:那项改动是持久化到日志的会话元数据;本项是不做持久化的终端标签。让二者相互独立可使各自自成一体,并避免共享依赖。
|
||||
|
||||
**阻塞首轮直到标题就绪。** 否决:在发送用户消息前先等待标题,会给实际请求增加延迟;发出后不等待其返回可让重命名对该轮次不可见。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 开启时,全新会话会多花一次无工具的模型调用,只带单条简短的用户消息和少量输出 token;默认关闭时它不产生任何开销。
|
||||
- 由于标题调用会打上 `sessionId`,它与会话的 `llm-replay` 游标共享:在以回放支撑的快照场景中开启 `autoTitle` 会消耗一条录制脚本条目。这正是它默认关闭、且脚本化 PTY fixture 用按工具分支的适配器而非回放来回答该调用的原因。
|
||||
- `packages/ui/tui/tests/tui.spec.ts` 用一个 mock `llm` 适配器固定该行为:生成的标题替换静态标题、过长输出以省略号截断、仅含空白的首条消息保留一次性名额、空回复或失败回复保留标题、恢复的会话从不触发,以及功能关闭 / 无服务 / 缺提供方 / 缺模型各路径都保留静态标题。一项关闭测试断言尚在进行的请求被中止。
|
||||
- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 证明真实的经 Loader 启动的路径:脚本化适配器以固定字符串回答无工具的标题调用,对话场景断言 OSC 0 序列抵达 PTY。启动场景不发送用户消息,因此它们从不触发该调用。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-auto-title-default-on.md: 35809e1ef6bade3e09c34b17608eff5f8fb5bd22
|
||||
2026-07-21-tui-auto-title-default-on.zh.md: aa20cfde1359605f2ac5a8f0427f4518c611ecd1
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Auto-title on by default, re-derived on resume
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-auto-title-default-on.zh.md)
|
||||
|
||||
> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events.
|
||||
|
||||
## Problem
|
||||
|
||||
The [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) shipped `autoTitle` off by default and, on a resumed session, kept the static title because the first `user/message` was already logged. In use both choices defeated the feature's purpose. A per-session descriptive pane title is what makes one tmux pane or terminal tab distinguishable from the next; leaving it off by default means the product ships an inert feature that almost no user turns on, and skipping re-derivation on resume means a resumed session — exactly the long-lived session most worth labelling — falls back to the shared static string. The user asked for a descriptive per-session name to be the normal experience.
|
||||
|
||||
## Decision
|
||||
|
||||
- `autoTitle` defaults **on** (`z.boolean().default(true)`, mirrored by `resolveTuiConfig`'s `?? true`). A deployment with an `llm` service and an agent provider/model gets a model-made pane title on every session without opting in; one without them keeps the static title, so default-on is inert where the call cannot run.
|
||||
- A **resumed** session re-derives the title on mount from its already-logged first `user/message`: `createTuiChat` scans `agent.session.events` for the first such event and feeds its text to the same one-shot `generateTitle`. The title is never persisted (the session header carries no title field), so it is always derived, never restored.
|
||||
- The one-shot latch is now simply `titleSettled = !resolved.autoTitle`. The prior pre-settle-on-resume clause is gone: on resume `generateTitle` runs once from the stored first message and then latches, so a message that arrives *after* the resume does not re-title. A fresh session has no stored `user/message` at mount, so the resume scan is a no-op and the live `session/event` listener titles the first message instead.
|
||||
- Everything else from the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) stands unchanged: the OSC 0 `runtime.terminal.setTitle` path, the model-summary shape (two-to-five lowercase words, first non-empty line, 40-char cap), the fire-and-forget `ctx.llm.stream` call that never touches the session or transcript, the shutdown `AbortController`, and every failure fallback (empty reply, missing `llm`, missing provider/model, whitespace-only prompt).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the feature off by default.** Rejected: this is a direct reversal of the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md)'s "default off" decision at the user's request. Off-by-default ships an inert feature; the descriptive name is only useful if it is the normal experience. The keyless-replay concern that motivated off-by-default is addressed by pinning `autoTitle: false` in the replay-backed snapshot scenarios rather than by suppressing it for every deployment.
|
||||
|
||||
**Persist the derived title in the session header.** Rejected: the header has no title field and adding one would make a terminal label into session metadata — the boundary the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) already drew against the log-backed session-title work. Re-deriving from the stored first message costs one tool-less call on resume and keeps the label a pure function of the conversation.
|
||||
|
||||
**Re-derive on resume from the latest message instead of the first.** Rejected: the title summarises what the session is *about*, which its opening request captures; a mid-conversation message would make the pane label drift as the work moves on.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A fresh session with a working `llm` now spends one extra tool-less model call by default (previously only when opted in); a resumed session spends one on mount. Deployments without an `llm` or provider/model are unaffected.
|
||||
- The replay-backed `examples/tui-agent/tests/tui.snapshot.ts` must opt **out**: it pins `autoTitle: false`, because a default-on title request is not among the recorded turns and `installLlmReplay` fails loud on an unrecorded request. The unit `packages/ui/tui/tests/tui.snapshot.ts` needs no opt-out — it mounts no `llm` service, so `generateTitle` short-circuits and the default flip is inert there. The interactive `examples/tui-agent/cordis.yml` and the scripted PTY fixture already set `autoTitle: true`, so the keyless smoke's OSC 0 assertion is unchanged.
|
||||
- `packages/ui/tui/tests/tui.spec.ts` pins the new defaults: the config-default test expects `autoTitle: true`; the disabled-path test now sets `autoTitle: false` explicitly; and the former "resumed session never fires" test is rewritten to assert re-derivation from the stored first message and that a later live message does not re-title. `docs/config-catalog.md` regenerates to "On by default".
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: 自动标题默认开启,恢复时重新推导
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-auto-title-default-on.md) | 中文
|
||||
|
||||
> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。
|
||||
|
||||
## Problem
|
||||
|
||||
[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 交付时 `autoTitle` 默认关闭,并且在恢复会话中因首条 `user/message` 已入日志而保留静态标题。实际使用中这两个选择都违背了该功能的初衷。让一个 tmux 窗格或终端标签页区别于下一个的,正是每会话各异的描述性窗格标题;默认关闭意味着产品交付了一个几乎无人开启的惰性功能,而恢复时不重新推导,则意味着恢复会话——恰恰是最值得标记的长命会话——退回到共用的静态字符串。用户要求把每会话的描述性名称做成常态体验。
|
||||
|
||||
## Decision
|
||||
|
||||
- `autoTitle` 默认**开启**(`z.boolean().default(true)`,`resolveTuiConfig` 以 `?? true` 与之对齐)。带有 `llm` 服务与 agent 提供方/模型的部署无需选择性开启即可在每个会话获得模型制作的窗格标题;不具备它们的部署保留静态标题,因此在调用无法运行处,默认开启是惰性的。
|
||||
- **恢复**会话在挂载时从其已入日志的首条 `user/message` 重新推导标题:`createTuiChat` 在 `agent.session.events` 中扫描首个此类事件,并把其文本喂给同一个一次性的 `generateTitle`。标题从不持久化(会话头不携带标题字段),因此它始终是推导得来,而非恢复而来。
|
||||
- 一次性门闩现在只是 `titleSettled = !resolved.autoTitle`。此前"恢复即预先结算"的分句已删除:恢复时 `generateTitle` 从已存储的首条消息运行一次随后上闩,因此恢复*之后*到达的消息不会再改标题。全新会话在挂载时没有已存储的 `user/message`,因此恢复扫描是空操作,改由实时的 `session/event` 监听器为首条消息命名。
|
||||
- [自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 的其余一切保持不变:OSC 0 的 `runtime.terminal.setTitle` 路径、模型概括形态(两到五个小写单词、首个非空行、40 字符上限)、从不触碰会话或 transcript(文本记录)的发出后不等待其返回的 `ctx.llm.stream` 调用、关闭时的 `AbortController`,以及每一条失败兜底(空回复、缺 `llm`、缺提供方/模型、仅含空白的提示词)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**让该功能保持默认关闭。** 否决:这是应用户要求,对[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)"默认关闭"决策的直接反转。默认关闭交付的是惰性功能;只有当描述性名称成为常态体验时它才有用。当初促成默认关闭的无密钥回放顾虑,改由在以回放支撑的快照场景中固定 `autoTitle: false` 来处理,而非为每个部署都压制该功能。
|
||||
|
||||
**把推导出的标题持久化进会话头。** 否决:会话头没有标题字段,加一个会把终端标签变成会话元数据——正是[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)已经对日志支撑的会话标题工作划出的边界。从已存储的首条消息重新推导,代价是恢复时一次无工具调用,并让标签保持为对话的纯函数。
|
||||
|
||||
**恢复时从最新消息而非首条消息重新推导。** 否决:标题概括的是会话*关于什么*,而这由其开场请求捕获;一条对话中途的消息会让窗格标签随工作推进而漂移。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 带可用 `llm` 的全新会话现在默认多花一次无工具的模型调用(此前只在选择性开启时才有);恢复会话在挂载时花掉一次。不具备 `llm` 或提供方/模型的部署不受影响。
|
||||
- 以回放支撑的 `examples/tui-agent/tests/tui.snapshot.ts` 必须选择**关闭**:它固定 `autoTitle: false`,因为默认开启的标题请求不在录制轮次之列,而 `installLlmReplay` 对未录制的请求会显式报错。单元 `packages/ui/tui/tests/tui.snapshot.ts` 无需选择关闭——它不挂载 `llm` 服务,因此 `generateTitle` 提前短路,默认值的翻转在那里是惰性的。交互式的 `examples/tui-agent/cordis.yml` 与脚本化 PTY fixture(测试前置数据)已设 `autoTitle: true`,因此无密钥冒烟测试的 OSC 0 断言保持不变。
|
||||
- `packages/ui/tui/tests/tui.spec.ts` 固定新的默认值:config 默认测试期望 `autoTitle: true`;关闭路径测试现在显式设 `autoTitle: false`;此前的"恢复会话从不触发"测试改写为断言从已存储首条消息重新推导,并断言之后的实时消息不会再改标题。`docs/config-catalog.md` 重新生成为"On by default"。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-banner-brand-gradient.md: 41edf5d0bcf856bc7695af6bf651ff04c11adc01
|
||||
2026-07-21-tui-banner-brand-gradient.zh.md: 9253c001e8df2a4d0f79f69f32d65c11afd13e22
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: TUI banner brand gradient
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-banner-brand-gradient.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The TUI startup banner rendered the product name `DEEPSEEK` in the palette's flat accent color, which carries no brand identity and does not resemble the wordmark on deepseek.com. The request was to make the banner match the site logo's blue gradient specifically — not to recolor the rest of the coding harness.
|
||||
|
||||
The banner is the one surface where that matters, and it conflicts with a load-bearing invariant: the TUI palette is deliberately theme-agnostic. It uses only standard 16-color ANSI (SGR) codes and attributes so a user's terminal scheme remaps every color; the `themeViolations()` snapshot gate rejects any RGB, extended-palette, or explicit-background cell. A smooth logo-matching gradient cannot be built from 16 palette colors, so reproducing it requires 24-bit truecolor, which the gate flags by design.
|
||||
|
||||
## Decision
|
||||
|
||||
The banner paints `DEEPSEEK` with a per-letter 24-bit truecolor foreground sweeping the deepseek.com brand gradient — `#4D6BFE` → `#3982FF` → `#2498FF` — via piecewise-linear interpolation across those three stops; `HARNESS` stays bold with the default foreground. The gradient is foreground-only, so it stays legible on any terminal background, and it is confined to the banner's product name. This is the sole sanctioned exception to the theme-agnostic palette; every other surface remains standard-ANSI and theme-adaptive.
|
||||
|
||||
The gradient is gated on `resolved.color && resolved.truecolor`. When truecolor is unavailable the banner falls back to the existing flat bright-blue accent, so nothing about the theme-agnostic guarantee or the recorded snapshots changes unless truecolor is explicitly in play.
|
||||
|
||||
`truecolor` is a validated `Config` field with no schema default. When it is unset, `apply()` auto-detects it at the process boundary from `COLORTERM` (`truecolor` or `24bit`); an explicit config value always wins. Detection reads `process.env` only in `apply()` — never in the pure `resolveTuiConfig` resolver — keeping the resolver a pure function of its input.
|
||||
|
||||
The gradient stops are fixed brand identity, treated like a protocol constant, so they are hardcoded in the plugin rather than exposed as a tunable. Whether truecolor is *enabled* is terminal- and deployment-varying, so that is the validated `Config` field. The banner text is UI-only and never reaches a model request, so no session event is required.
|
||||
|
||||
## Testing
|
||||
|
||||
A dedicated `banner-gradient` terminal snapshot pins the real per-letter RGB output in an xterm emulator (`fg=#4d6bfe`…`#2498ff`, each letter bold). The shared `checkpoint()` helper takes a `bannerGradient` flag: for that one checkpoint it asserts the theme violations are non-empty and that every violation ends in `rgb-fg` — i.e. truecolor is present but confined to the banner foreground, with no background or extended-palette leak. Every other checkpoint keeps the strict `themeViolations()` `.toEqual([])` assertion, so the fence is mechanically enforced. A `tui.spec.ts` unit test mounts with `color`+`truecolor` enabled to cover the header's gradient branch and the `gradientText`/`brandColorAt` helpers.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A theme-safe stepped gradient built from the 16-color palette.** Approximating the sweep with bright-blue palette variants would keep the banner fully theme-agnostic and avoid touching the gate. It was rejected by the requester: 16 fixed colors cannot reproduce the smooth logo gradient, and the request was explicitly to match the site wordmark.
|
||||
|
||||
**Recoloring the whole harness palette blue.** The original phrasing was "update the harness color to blue." That was narrowed to the banner only; a global blue palette would break theme-agnosticism everywhere, not just on one brand surface.
|
||||
|
||||
**Always emitting truecolor.** Many terminals lack 24-bit support and would render the raw or degraded codes. Gating on detection with an ANSI fallback keeps the banner correct everywhere while still showing the gradient where it works.
|
||||
|
||||
**Detecting truecolor inside `resolveTuiConfig`.** The resolver is a pure defaulting step and must not read `process.env`. Environment probing belongs at the process boundary in `apply()`, so `mountTui`/`createTuiChat` stay driven purely by their config input and remain fully testable with a fake terminal.
|
||||
|
||||
## Consequences
|
||||
|
||||
The banner now carries the DeepSeek brand identity on truecolor terminals while the theme-agnostic guarantee holds everywhere else — and even on the banner itself when truecolor is unavailable. The cost is one narrow, documented crack in the theme-agnostic invariant: a fixed-color surface that will not adapt to a user's terminal scheme, accepted because it is brand identity and foreground-only, so it stays legible on both light and dark backgrounds. The crack is fenced by the `banner-gradient` snapshot assertion, which confines truecolor to the banner foreground and fails if any other RGB, extended-palette, or background color ever appears.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: TUI 启动横幅品牌渐变
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-banner-brand-gradient.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
TUI 启动横幅原本用调色板的扁平强调色渲染产品名 `DEEPSEEK`,它不承载任何品牌标识,也不像 deepseek.com 上的字标。需求明确是让横幅匹配站点 logo 的蓝色渐变——而不是给整个 coding harness 重新上色。
|
||||
|
||||
横幅是唯一在意这件事的界面,而它与一条承重不变量冲突:TUI 调色板刻意做到主题无关。它只使用标准 16 色 ANSI(SGR)代码与属性,好让用户的终端配色方案能够重映射每一种颜色;`themeViolations()` 快照门禁会拒绝任何 RGB、扩展调色板或显式背景色的单元格。用 16 种调色板颜色无法拼出平滑的、与 logo 一致的渐变,因此复现它需要 24 位真彩色(truecolor),而门禁按设计会将其标记出来。
|
||||
|
||||
## 决策
|
||||
|
||||
横幅用逐字母的 24 位真彩色前景色渲染 `DEEPSEEK`,沿 deepseek.com 品牌渐变——`#4D6BFE` → `#3982FF` → `#2498FF`——在这三个色标之间做分段线性插值;`HARNESS` 保持加粗并使用默认前景色。渐变仅作用于前景色,因此在任何终端背景上都保持可读,并且被限制在横幅的产品名内。这是主题无关调色板唯一获准的例外;其余每个界面都保持标准 ANSI 且随主题自适应。
|
||||
|
||||
渐变以 `resolved.color && resolved.truecolor` 为开关。当真彩色不可用时,横幅回退到既有的扁平亮蓝强调色,因此除非显式启用真彩色,主题无关保证与已录制的快照都不会改变。
|
||||
|
||||
`truecolor` 是一个经校验的 `Config` 字段,schema 不设默认值。当它未设置时,`apply()` 会在进程边界从 `COLORTERM`(`truecolor` 或 `24bit`)自动探测;显式的配置值始终优先。探测只在 `apply()` 中读取 `process.env`——绝不在纯粹的 `resolveTuiConfig` 解析器中——从而让解析器保持为其输入的纯函数。
|
||||
|
||||
渐变色标是固定的品牌标识,被当作协议常量对待,因此硬编码在插件里,而不作为可调项暴露。是否*启用*真彩色则随终端与部署而变,所以那才是经校验的 `Config` 字段。横幅文本仅面向界面,永不进入任何模型请求,因此不需要会话事件。
|
||||
|
||||
## 测试
|
||||
|
||||
一个专门的 `banner-gradient` 终端快照在 xterm 模拟器中固定了真实的逐字母 RGB 输出(`fg=#4d6bfe`…`#2498ff`,每个字母加粗)。共享的 `checkpoint()` 辅助函数接受一个 `bannerGradient` 标志:仅对该 checkpoint,它断言主题违规项非空,且每一项都以 `rgb-fg` 结尾——即真彩色确实存在,但被限制在横幅前景色,没有背景色或扩展调色板的泄漏。其余每个 checkpoint 都保持严格的 `themeViolations()` `.toEqual([])` 断言,因此这道围栏是机械强制的。一个 `tui.spec.ts` 单元测试在同时启用 `color` 与 `truecolor` 时挂载,以覆盖 header 的渐变分支以及 `gradientText`/`brandColorAt` 辅助函数。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**用 16 色调色板拼出的主题安全阶梯渐变。** 用亮蓝的调色板变体近似这段渐变可以让横幅完全保持主题无关,并避免触碰门禁。它被需求方否决了:16 种固定颜色无法复现平滑的 logo 渐变,而需求明确是匹配站点字标。
|
||||
|
||||
**给整个 harness 调色板重新上蓝色。** 最初的说法是"把 harness 颜色改成蓝色"。它被收窄到只改横幅;全局蓝色调色板会在各处而非仅一个品牌界面上破坏主题无关性。
|
||||
|
||||
**始终发射真彩色。** 许多终端不支持 24 位,会渲染出原始或降级的代码。以探测为开关并配以 ANSI 回退,能让横幅在各处都正确,同时仍在支持的地方展示渐变。
|
||||
|
||||
**在 `resolveTuiConfig` 内探测真彩色。** 该解析器是纯粹的默认值填充步骤,绝不能读取 `process.env`。环境探测属于 `apply()` 中的进程边界,从而让 `mountTui`/`createTuiChat` 完全由其配置输入驱动,并在使用假终端时保持完全可测。
|
||||
|
||||
## 后果
|
||||
|
||||
现在横幅会在真彩色终端上承载 DeepSeek 品牌标识,而主题无关保证在其余各处依然成立——甚至当真彩色不可用时在横幅自身上也成立。代价是主题无关不变量上一道狭窄且有记录的裂缝:一个不会随用户终端配色方案自适应的固定颜色界面,之所以接受,是因为它是品牌标识且仅作用于前景色,从而在浅色与深色背景上都保持可读。这道裂缝由 `banner-gradient` 快照断言把守,它将真彩色限制在横幅前景色,一旦其他任何 RGB、扩展调色板或背景色出现就会失败。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-banner-sweep.md: c146424d53e75a72b63e346f87a5bbd206d67350
|
||||
2026-07-21-tui-banner-sweep.zh.md: 01cc153e88f067b7b8d2eb6317648f3892fe8a5a
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: The banner sweeps in; the subtitle line is gone
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-banner-sweep.zh.md)
|
||||
|
||||
> **Superseded** by the [no-banner Agent Note](2026-07-21-tui-no-banner.md): the banner itself was removed, taking the sweep with it.
|
||||
|
||||
## Problem
|
||||
|
||||
The [startup-slogans Agent Note](2026-07-20-tui-startup-slogans.md) replaced the instructional welcome line with a random slogan bank revealed by a per-character typewriter. In use the quotes read as weird — random flavor text in a tool's header — and the animation was slow (40 ms/char over a full sentence) while animating only one line of a four-line banner. This note supersedes that decision's slogan half; the removal of the configured demo welcome and the animation-lifecycle groundwork stand.
|
||||
|
||||
## Decision
|
||||
|
||||
- The slogan bank, `pickStartupSlogan`, and the typewriter reveal are deleted. When `welcome` is unset the banner simply has **no subtitle line** — title and model/session detail only. The `welcome` config remains for deployments and fixtures that want a fixed subtitle, rendered frame-deterministically with no animation.
|
||||
- The startup animation is now the **whole banner**: `HeaderComponent` gains a `revealWidth` clip, and the header box wipes in left-to-right over ~24 frames at 15 ms (~360 ms total, ~60 fps), started after `ui.start()` succeeds and cleared through the same `detachListeners` path the typewriter used. `stopBannerReveal` also resets the clip so a disposed-mid-sweep header re-renders whole.
|
||||
- The PTY smoke's boot marker changes from the typewriter cursor (`▌`) to the banner's top-right corner (`╮`), which only renders once the sweep completes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the animation as-is and only change the copy.** Rejected: any fixed or rotating phrase re-read on every boot decays into wallpaper; the user's judgment was that the quotes themselves, not just their content, were wrong for the surface.
|
||||
|
||||
**Animate per banner line (top-down) instead of a left-right sweep.** Rejected: with only four lines the animation would have four visible steps — closer to a flicker than a reveal; the horizontal sweep uses the full terminal width for a smooth motion at the same total duration.
|
||||
|
||||
**Character-level clipping via `revealWidth` on styled text.** Adopted with `truncateToWidth` from pi-tui, the same ANSI-aware clipper the header already uses for width overflow, so the sweep cannot tear escape sequences.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Boot output with `welcome` unset is again animation-dependent but no longer random: every boot sweeps the same banner. Configured welcomes (all snapshot/scripted fixtures, the Code Mode overlay) stay frame-deterministic and unchanged.
|
||||
- The `STARTUP_SLOGANS`/`pickStartupSlogan` exports are gone; no consumer outside the deleted tests referenced them.
|
||||
- The default banner is one line shorter (no subtitle), so PTY assertions anchored on banner geometry use the corner glyph rather than any subtitle text.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins: the sweep completes to a full banner (both corners + title) and produced at least one clipped mid-sweep frame; a configured welcome renders verbatim with no clipped frames; the unset-welcome banner has no subtitle; and dispose clears the sweep's own interval handle. The PTY smoke boots on the `╮` completion marker across the tui-demo bin, the dsh CLI, and the personal-overlay scenarios. Verified live in tmux.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 横幅整体扫入;副标题行移除
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-banner-sweep.md) | 中文
|
||||
|
||||
> **已被取代**:由[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md)取代:横幅本身已移除,扫入动画随之移除。
|
||||
|
||||
## Problem
|
||||
|
||||
[启动 slogan Agent Note](2026-07-20-tui-startup-slogans.md) 用随机 slogan 库加逐字打字机动画取代了说明书式的欢迎行。实际使用中这些引语显得怪异——工具头部出现随机的风味文案——而且动画很慢(每字符 40 ms,扫完一整句),却只动画四行横幅中的一行。本 note 取代该决定中 slogan 的那一半;移除示例配置中欢迎语的决定与动画生命周期的基础设施保持不变。
|
||||
|
||||
## Decision
|
||||
|
||||
- 删除 slogan 库、`pickStartupSlogan` 和打字机动画。`welcome` 未设置时横幅直接**没有副标题行**——只有标题和模型/会话详情。`welcome` 配置保留给想要固定副标题的部署与 fixture,无动画、逐帧确定地渲染。
|
||||
- 启动动画现在作用于**整个横幅**:`HeaderComponent` 增加 `revealWidth` 裁剪,头部盒子以约 24 帧、每帧 15 ms(总计约 360 ms、约 60 fps)从左到右扫入,在 `ui.start()` 成功后启动,经打字机动画用过的同一条 `detachListeners` 路径清除。`stopBannerReveal` 同时重置裁剪,因此扫入中途被 dispose 的头部会重新完整渲染。
|
||||
- PTY 冒烟测试的启动标记从打字机光标(`▌`)改为横幅右上角(`╮`),它只在扫入完成后才渲染。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留动画原样、只改文案。** 否决:任何每次启动都被重读的固定或轮换语句都会退化成墙纸;用户的判断是引语本身——而不只是内容——对这个表面来说就是错的。
|
||||
|
||||
**按横幅行逐行(自上而下)动画而非左右扫入。** 否决:只有四行时动画只有四个可见步骤——更像闪烁而不是展开;水平扫入用满终端宽度,在相同总时长内动作更平滑。
|
||||
|
||||
**用 `revealWidth` 对带样式文本做字符级裁剪。** 采用 pi-tui 的 `truncateToWidth`——头部处理宽度溢出时已在使用的同一个 ANSI 感知裁剪器——因此扫入不可能撕裂转义序列。
|
||||
|
||||
## Consequences
|
||||
|
||||
- `welcome` 未设置时启动输出再次依赖动画但不再随机:每次启动扫入同一幅横幅。配置了欢迎语的场景(全部快照/脚本化 fixture、Code Mode overlay)保持逐帧确定且不变。
|
||||
- `STARTUP_SLOGANS`/`pickStartupSlogan` 导出移除;除被删除的测试外没有消费者引用它们。
|
||||
- 默认横幅少一行(无副标题),因此锚定横幅几何的 PTY 断言使用角落字形而非任何副标题文本。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定:扫入完成为完整横幅(两个角 + 标题)且产生了至少一个裁剪的中途帧;配置的欢迎语原文渲染且无裁剪帧;未设置欢迎语的横幅没有副标题;dispose 清除扫入自己的定时器句柄。PTY 冒烟测试在 tui-demo bin、dsh CLI 和个人 overlay 场景中以 `╮` 完成标记启动。已在 tmux 中实机验证。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-borderless-banner.md: 37263854b6cc77283215c3c1378f9908ff966611
|
||||
2026-07-21-tui-borderless-banner.zh.md: ca796e49cb9d3a9abc0acd64a39448bc3f9ad50e
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: The banner returns, borderless
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-borderless-banner.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [no-banner Agent Note](2026-07-21-tui-no-banner.md) removed the boxed startup banner: it deleted `HeaderComponent` and its sweep, moved the model into the footer, dropped the session id, and rendered `welcome` as the transcript's first line. The user's verdict reversed that: bring the banner back — "just remove the border". The four-row box frame was the objectionable chrome, not the identifying facts it carried (model, session id) nor the sweep-in motion.
|
||||
|
||||
## Decision
|
||||
|
||||
- `HeaderComponent` and its left-to-right sweep return, but render **borderless**: no `╭─╮`/`╰─╯` corners and no `│` side bars. Each line is a single leading space plus `truncateToWidth`-clipped content, so the sweep's width clip can never tear an escape sequence and no fixed frame is drawn.
|
||||
- The header carries the title (`DEEPSEEK HARNESS`), a `<model> • <session-id>` detail line, and — when `welcome` is set — a muted subtitle. With `welcome` unset the header is title + detail only.
|
||||
- The model **also** stays in the footer's left segment. The no-banner note's footer model prefix is kept, not reverted, so the driving model stays glanceable after the transient banner scrolls out of view.
|
||||
- `welcome` reverts to a banner subtitle; the transcript-first-line notice is removed from `rebuildTranscript`.
|
||||
- The sweep animates only when `welcome` is unset. A configured `welcome` renders the whole banner immediately, keeping fixtures and snapshots frame-deterministic. The sweep starts after `ui.start()` succeeds and is cleared through the same `detachListeners` path via `stopBannerReveal`, which also resets the clip so a header disposed mid-sweep re-renders whole.
|
||||
|
||||
This supersedes the [no-banner Agent Note](2026-07-21-tui-no-banner.md) (which superseded the [banner-sweep Agent Note](2026-07-21-tui-banner-sweep.md)): the banner and its sweep return borderless, while the model's footer home the no-banner note added stays.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the box but thin it or use lighter glyphs.** Rejected: the instruction was "just remove the border"; any surrounding glyph is the frame chrome the user objected to.
|
||||
|
||||
**Drop the model from the footer now that the banner shows it again.** Rejected: the banner is transient and scrolls away with the transcript, while the footer keeps the model visible for the whole session — the reason the no-banner note put it there, deliberately preserved.
|
||||
|
||||
**Leave the session id out, as the no-banner note decided.** Rejected: with the box gone the detail line costs one row, and the user asked for the banner "as before", which carried `model • session-id`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Boot output with `welcome` unset is animation-dependent again (the sweep); configured welcomes stay frame-deterministic, so every snapshot and scripted fixture keeps a fixed subtitle.
|
||||
- The model now appears twice at boot — banner detail and footer — intended redundancy: the banner is transient, the footer persistent.
|
||||
- `/clear` empties the transcript but not the header, so the banner and its configured subtitle survive `/clear`, unlike the no-banner welcome line that `/clear` wiped.
|
||||
- All pi-tui terminal snapshots and the examples/tui-agent replay snapshots re-recorded (`test:snapshot:refresh`): banner rows return with no box glyphs; footer rows keep the model prefix.
|
||||
- Anything that anchored on banner absence re-anchors on its presence: the PTY smoke boots on the detail line's `main-session-` id (revealed late in the sweep) and asserts `DEEPSEEK`/`HARNESS` present with no box corners.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins: the borderless banner sweeps to natural completion — no box corners, title and `main-session` detail present — with at least one clipped mid-sweep frame; a configured `welcome` renders the whole banner with no clipped frame; the unset-welcome banner has no subtitle; and dispose clears the sweep interval mid-sweep. The tui-agent and dsh-CLI PTY smokes boot on the `main-session-` detail marker and assert no box corners. Snapshots verify the full frames.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 横幅回归,无边框
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-borderless-banner.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
[移除横幅 Agent Note](2026-07-21-tui-no-banner.md) 删掉了带框的启动横幅:它删除了 `HeaderComponent` 及其扫入动画,把模型移入页脚,丢弃了会话 id,并把 `welcome` 渲染为 transcript 的第一行。用户的裁决把这一切反转:把横幅拿回来——"just remove the border"。令人反感的装饰是那四行盒子边框,而不是它承载的识别信息(模型、会话 id),也不是扫入动效。
|
||||
|
||||
## Decision
|
||||
|
||||
- `HeaderComponent` 及其从左到右的扫入动画回归,但以**无边框**方式渲染:没有 `╭─╮`/`╰─╯` 边角,也没有 `│` 侧边。每一行都是一个前导空格加上经 `truncateToWidth` 裁剪的内容,因此扫入的宽度裁剪永远不会撕裂转义序列,也不绘制任何固定边框。
|
||||
- 头部承载标题(`DEEPSEEK HARNESS`)、一条 `<model> • <session-id>` 详情行,以及——当设置了 `welcome` 时——一条弱化的副标题。`welcome` 未设置时头部只有标题加详情。
|
||||
- 模型**同时**保留在页脚的左段。移除横幅那版 note 加入的页脚模型前缀被保留而非回退,因此在短暂的横幅滚出视野后,会话使用的模型仍可一瞥可见。
|
||||
- `welcome` 恢复为横幅副标题;transcript 第一行的通知从 `rebuildTranscript` 中移除。
|
||||
- 仅当 `welcome` 未设置时才播放扫入动画。配置了 `welcome` 会立即渲染整个横幅,使 fixture 和快照保持帧确定性。扫入在 `ui.start()` 成功后启动,并经与之前相同的 `detachListeners` 路径通过 `stopBannerReveal` 清理;后者还会重置裁剪,使扫入中途被销毁的头部重新完整渲染。
|
||||
|
||||
本 note 取代[移除横幅 Agent Note](2026-07-21-tui-no-banner.md)(后者取代了[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md)):横幅及其扫入动画以无边框方式回归,而移除横幅那版 note 为模型设立的页脚归宿得以保留。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留盒子但做细或改用更轻的字符。** 否决:指令是 "just remove the border";任何环绕的字符都是用户所反对的边框装饰。
|
||||
|
||||
**既然横幅重新显示模型,就把模型从页脚移除。** 否决:横幅是短暂的,会随 transcript 滚走,而页脚在整个会话中保持模型可见——这正是移除横幅那版 note 把它放在那里的原因,此处刻意保留。
|
||||
|
||||
**像移除横幅那版 note 那样,把会话 id 留在外面。** 否决:盒子去掉后详情行只占一行,且用户要求横幅"和以前一样",而以前它承载 `model • session-id`。
|
||||
|
||||
## Consequences
|
||||
|
||||
- `welcome` 未设置时的启动输出再次依赖动画(扫入);配置了欢迎语则保持帧确定性,因此每个快照和脚本 fixture 都保留一个固定副标题。
|
||||
- 模型现在在启动时出现两次——横幅详情与页脚——这是有意的冗余:横幅短暂,页脚常驻。
|
||||
- `/clear` 清空 transcript 但不清头部,因此横幅及其配置的副标题在 `/clear` 后存活,不同于被 `/clear` 清掉的移除横幅那版的欢迎行。
|
||||
- 全部 pi-tui 终端快照与 examples/tui-agent 回放快照重新录制(`test:snapshot:refresh`):横幅行以无盒子字符方式回归;页脚行保留模型前缀。
|
||||
- 一切锚定横幅缺失的内容改为锚定其存在:PTY 冒烟测试以详情行的 `main-session-` id 为启动标记(它在扫入后段才被揭示),并断言 `DEEPSEEK`/`HARNESS` 出现且无盒子角。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定:无边框横幅扫入至自然完成——无盒子角、标题与 `main-session` 详情出现——且至少有一帧扫入中途被裁剪;配置的 `welcome` 完整渲染横幅且无裁剪帧;未设置 `welcome` 的横幅无副标题;销毁会在扫入中途清掉扫入定时器。tui-agent 与 dsh CLI 的 PTY 冒烟测试以 `main-session-` 详情标记为启动标记并断言无盒子角。快照验证完整帧。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-footer-cache-hit-rate.md: aaee8ed31ff8f20370f490d3ce27c8705cda3e16
|
||||
2026-07-21-tui-footer-cache-hit-rate.zh.md: 67a7aa474d98878a5bc0bc0a76a8c2ccad004e9b
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: TUI footer shows the session cache hit rate
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-footer-cache-hit-rate.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The footer summed the session's token usage as `↑<input> ↓<output>`, where `↑` is the uncached input reported by the model. `TokenUsage` counts are disjoint: billed prompt tokens are `inputTokens` (uncached) plus `cacheReadTokens` and `cacheWriteTokens`. With only the uncached number visible, a user could not tell how much of each turn's prompt the provider cache served — the signal that most directly reflects whether the reused request prefix is paying off. On a long session dominated by cache reads the `↑` figure stays small and hides that the prompt is large but cheap.
|
||||
|
||||
## Decision
|
||||
|
||||
The footer appends `cache <rate>%` after `↑<input> ↓<output>`, where the rate is the share of billed prompt tokens served from the provider cache.
|
||||
|
||||
- `TokenTotals` accumulates the four disjoint buckets (`input`, `output`, `cacheRead`, `cacheWrite`). `addUsage` folds one call's `TokenUsage` into the totals, treating a missing `cacheReadTokens`/`cacheWriteTokens` as zero.
|
||||
- `cacheHitRate(totals)` is `round(cacheRead / (input + cacheRead + cacheWrite) * 100)`, and `undefined` before any input is billed. `FooterComponent` omits the whole ` cache N%` segment while the rate is `undefined`, so an empty session shows no meaningless zero.
|
||||
- `↑` keeps meaning uncached input, not billed input: the disjoint-bucket convention holds across the footer, and the cache percent supplies the reuse signal the raw counts cannot.
|
||||
- Totals are rebuilt on mount by `sessionTokens`, which sums usage over `assistant/message` events (never `assistant/chunk`, to avoid double counting), and updated live from each `assistant/message` event that carries usage.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Show billed input (`input + cacheRead + cacheWrite`) as `↑` instead of a separate percent.** Rejected: it would redefine `↑` away from the disjoint `inputTokens` bucket the rest of the harness reports, and it would still hide the reuse share the user actually wants; a derived percent adds the signal without overloading the count.
|
||||
|
||||
**Compute the rate against all tokens (`input + output + cache`).** Rejected: output tokens are never cache-served, so folding them into the denominator understates the rate for no meaning; cache hit rate is a property of the prompt.
|
||||
|
||||
**Drop `cacheWrite` from the denominator.** Rejected: cache writes are billed input the provider spent to populate the cache, so excluding them overstates the hit rate on a writing turn. DeepSeek reports no cache-write metric today, but the formula stays general and the write path is covered.
|
||||
|
||||
**Render `cache 0%` on an empty session.** Rejected: the billed input is `0`, the ratio is `0/0`, and a `0%` badge on a fresh session is a lie about a value that does not exist yet; the segment stays hidden until input is billed.
|
||||
|
||||
**Give the metric its own right-aligned footer element beside `tools:`.** Rejected: it derives from the adjacent token counts and reads best in the `input → output → cache` order; grouping it left also keeps the lower-priority `tools:` indicator as the element that clips first under width pressure, matching the footer's existing layout priority.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The left group grew by ` cache N%`, so on a narrow footer the right-side `tools:` state clips sooner. This follows the footer's pre-existing left-priority truncation and is an accepted trade-off.
|
||||
- The metric is best-effort live UI state derived from `assistant/message` usage: rebuilt from the session on mount, updated live, and never persisted.
|
||||
- `packages/ui/tui/src/index.ts` stays at 100 % per-file coverage.
|
||||
- The `examples/tui-agent` terminal snapshots carry the segment: a turn with cache reads renders e.g. `cache 49%`, and a first cold turn renders `cache 0%`.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` drives the footer through the real `createTuiChat`: an empty session renders `↑0 ↓0` with no cache segment (the hidden path), a cold turn (`inputTokens` only) renders `cache 0%`, and a live warm turn carrying `cacheReadTokens` and `cacheWriteTokens` updates it to `cache 60%` while no longer showing `cache 0%`. The `examples/tui-agent` snapshot suite replays green against the recorded expected output.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: TUI 页脚展示会话缓存命中率
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-footer-cache-hit-rate.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
页脚原本把会话的 token 用量汇总为 `↑<input> ↓<output>`,其中 `↑` 是模型上报的未缓存输入。`TokenUsage` 的各项计数互不重叠:计费的输入 token 由 `inputTokens`(未缓存)加上 `cacheReadTokens` 与 `cacheWriteTokens` 构成。只暴露未缓存的那个数字,用户就无从判断每轮提示词有多少由提供方缓存承接——而这恰是最能反映复用的请求前缀是否奏效的信号。在以缓存读取为主的长会话里,`↑` 始终很小,掩盖了提示词其实很大但很便宜的事实。
|
||||
|
||||
## Decision
|
||||
|
||||
页脚在 `↑<input> ↓<output>` 之后追加 `cache <rate>%`,该比率是计费输入 token 中由提供方缓存承接的占比。
|
||||
|
||||
- `TokenTotals` 累加四个互不重叠的桶(`input`、`output`、`cacheRead`、`cacheWrite`)。`addUsage` 把单次调用的 `TokenUsage` 折入总量,缺失的 `cacheReadTokens`/`cacheWriteTokens` 视为零。
|
||||
- `cacheHitRate(totals)` 为 `round(cacheRead / (input + cacheRead + cacheWrite) * 100)`,在尚无输入计费前返回 `undefined`。比率为 `undefined` 时 `FooterComponent` 整段略去 ` cache N%`,因此空会话不会显示无意义的零。
|
||||
- `↑` 仍表示未缓存输入,而非计费输入:页脚全程遵守互不重叠的桶约定,缺失的复用信号由缓存百分比补足。
|
||||
- 挂载时由 `sessionTokens` 重建总量,它对带 usage 的 `assistant/message` 事件求和(绝不用 `assistant/chunk`,以免重复计数);此后每条携带 usage 的 `assistant/message` 事件都会实时更新。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把计费输入(`input + cacheRead + cacheWrite`)作为 `↑`,不单列百分比。** 否决:这会让 `↑` 偏离 harness 其余部分上报的互不重叠 `inputTokens` 桶,且仍旧藏住用户真正想要的复用占比;派生一个百分比既补上信号,又不给计数加载额外含义。
|
||||
|
||||
**用全部 token(`input + output + cache`)作分母计算比率。** 否决:输出 token 从不由缓存承接,把它折进分母只会无意义地拉低比率;缓存命中率是提示词的属性。
|
||||
|
||||
**从分母里去掉 `cacheWrite`。** 否决:缓存写入是提供方为填充缓存而付费的计费输入,剔除它会在写入的那一轮高估命中率。DeepSeek 目前不上报缓存写入指标,但公式保持通用,写入路径也有覆盖。
|
||||
|
||||
**在空会话上渲染 `cache 0%`。** 否决:此时计费输入为 `0`,比值是 `0/0`,在全新会话上打出 `0%` 是对一个尚不存在的值撒谎;在输入计费之前该段一直隐藏。
|
||||
|
||||
**给该指标单独一个右对齐的页脚元素,紧挨 `tools:`。** 否决:它派生自相邻的 token 计数,按 `input → output → cache` 的顺序阅读最顺;左置分组还让优先级更低的 `tools:` 指示成为宽度紧张时最先被裁剪的元素,与页脚既有的布局优先级一致。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 左段增加了 ` cache N%`,因此窄终端上右侧的 `tools:` 状态更早被裁剪。这沿用页脚既有的左段优先裁剪策略,是可接受的取舍。
|
||||
- 该指标是从 `assistant/message` 的 usage 派生的尽力而为实时 UI 状态:挂载时从会话重建、随后实时更新、从不持久化。
|
||||
- `packages/ui/tui/src/index.ts` 保持 100% 单文件覆盖率。
|
||||
- `examples/tui-agent` 终端快照带有该段:有缓存读取的一轮渲染为如 `cache 49%`,首个冷启动轮次渲染为 `cache 0%`。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 通过真实的 `createTuiChat` 驱动页脚:空会话渲染 `↑0 ↓0` 且无缓存段(隐藏路径),冷启动一轮(仅 `inputTokens`)渲染 `cache 0%`,随后实时的热轮次携带 `cacheReadTokens` 与 `cacheWriteTokens`,把它更新为 `cache 60%` 且不再显示 `cache 0%`。`examples/tui-agent` 快照套件对已录制的预期输出回放通过。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-no-banner.md: f5f4b1b847740e741ec3e33a6116e7497e955bd1
|
||||
2026-07-21-tui-no-banner.zh.md: 956fe03e2c0b09ea7378ffd53ffbe8d712d1e152
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: No startup banner
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-no-banner.zh.md)
|
||||
|
||||
> **Superseded** by the [borderless-banner Agent Note](2026-07-21-tui-borderless-banner.md): the banner and its sweep return without the box. The model's footer home this note added stays.
|
||||
|
||||
## Problem
|
||||
|
||||
The TUI opened with a boxed product banner ("DEEPSEEK HARNESS" + model/session detail), most recently with a sweep-in animation ([banner sweep Agent Note](2026-07-21-tui-banner-sweep.md)). The user's verdict: remove it. A product title re-read on every boot is chrome, the box spends four rows before any content, and the identifying facts it carried (model, session) have better homes.
|
||||
|
||||
## Decision
|
||||
|
||||
- `HeaderComponent`, the sweep animation, and its lifecycle wiring are deleted. The TUI mounts straight into the transcript; startup renders nothing above the separator.
|
||||
- The model name moves into the footer status line's left segment (`<model> <cwd> ↑tokens ↓tokens`), so the session's driving model stays visible at all times, not just at boot. The session id is no longer displayed — it lives in the session log and `./.sessions` filenames, and `RESUME_SESSION_ID` consumers retrieve it there.
|
||||
- `welcome`, when configured, renders as the transcript's first line (a muted notice) inside `rebuildTranscript`, so palette swaps preserve it. Unset renders nothing. Fixtures keep their configured welcomes; the PTY smoke's boot marker becomes the footer's model name, the only mounted-TUI text guaranteed to render regardless of cwd length.
|
||||
|
||||
This supersedes the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md) entirely: both the sweep and the banner it animated are gone.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep a one-line header (no box).** Rejected: the only load-bearing fact was the model name, and the footer already aggregates session status; a dedicated header row for one fact is the same chrome, smaller.
|
||||
|
||||
**Show the session id in the footer too.** Rejected: a 36-char UUID dominates the 100-column footer and clips the status segment; it identifies the session for resume, which is a log/filesystem concern, not a glanceable one.
|
||||
|
||||
**Print the welcome outside the transcript (above the separator).** Rejected: any fixed region above the transcript is a banner again; as a transcript line it scrolls away naturally and survives rebuilds through the same path as every other transcript element.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Startup output is fully deterministic again — no animation frames at all; the interval-lifecycle machinery from the two animation iterations is gone.
|
||||
- All 26 pi-tui terminal snapshots re-recorded (`test:snapshot:refresh`): banner rows gone, footer rows gain the model prefix.
|
||||
- Anything that anchored on banner text (`DEEPSEEK`, box corners) re-anchors on the footer model name; `main-session-` no longer appears in boot output.
|
||||
- `/clear` now wipes the welcome line too: it is an ordinary transcript line, and `/clear` empties the transcript (the old banner survived `/clear` only by sitting outside it).
|
||||
- The footer's left segment is wider; on narrow terminals the right status segment clips earlier.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins: no box corners/product title and an empty transcript when `welcome` is unset, with the model in the footer; a configured welcome as the first transcript line without a banner; and the welcome surviving a palette-swap transcript rebuild. The PTY smoke boots on the footer model name and asserts `DEEPSEEK HARNESS` is absent. Snapshots verify the full frames.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 移除启动横幅
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-no-banner.md) | 中文
|
||||
|
||||
> **已被取代**,见[无边框横幅 Agent Note](2026-07-21-tui-borderless-banner.md):横幅及其扫入动画回归,只是去掉了盒子。本 note 为模型设立的页脚归宿得以保留。
|
||||
|
||||
## Problem
|
||||
|
||||
TUI 启动时展示一个带框的产品横幅("DEEPSEEK HARNESS" + 模型/会话详情),最近一版还带扫入动画([横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md))。用户的裁决:删掉它。每次启动都被重读的产品标题是装饰,盒子在任何内容之前先占掉四行,而它承载的识别信息(模型、会话)有更好的去处。
|
||||
|
||||
## Decision
|
||||
|
||||
- 删除 `HeaderComponent`、扫入动画及其生命周期接线。TUI 直接挂载进 transcript;启动时分隔线之上不渲染任何东西。
|
||||
- 模型名移入页脚状态行的左段(`<model> <cwd> ↑tokens ↓tokens`),会话使用的模型因此始终可见,而不只是启动时。会话 id 不再显示——它存在于会话日志和 `./.sessions` 文件名中,`RESUME_SESSION_ID` 的使用者从那里获取。
|
||||
- 配置了 `welcome` 时,它作为 transcript 的第一行(一条弱化的通知)在 `rebuildTranscript` 内渲染,因此调色板切换会保留它。未设置则什么也不渲染。fixture 保留各自配置的欢迎语;PTY 冒烟测试的启动标记改为页脚的模型名——无论 cwd 多长都保证渲染的唯一挂载后文本。
|
||||
|
||||
本 note 完全取代[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md):扫入动画和它所动画的横幅都已移除。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留单行头部(去掉盒子)。** 否决:唯一有承载价值的信息是模型名,而页脚已经聚合会话状态;为一条信息保留专用头部行仍是同一种装饰,只是小一点。
|
||||
|
||||
**把会话 id 也放进页脚。** 否决:36 字符的 UUID 会占满 100 列页脚并裁掉状态段;它的用途是恢复会话的标识,属于日志/文件系统关注点,不是需要一瞥可见的信息。
|
||||
|
||||
**把欢迎语渲染在 transcript 之外(分隔线上方)。** 否决:transcript 上方任何固定区域都会再次变成横幅;作为 transcript 行它自然滚走,并通过与其他 transcript 元素相同的路径在重建后保留。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 启动输出再次完全确定——没有任何动画帧;两轮动画迭代留下的定时器生命周期机制全部移除。
|
||||
- 全部 26 个 pi-tui 终端快照重新录制(`test:snapshot:refresh`):横幅行消失,页脚行增加模型前缀。
|
||||
- 锚定横幅文本(`DEEPSEEK`、盒子角)的内容改为锚定页脚模型名;启动输出中不再出现 `main-session-`。
|
||||
- `/clear` 现在也会清掉欢迎行:它是普通的 transcript 行,而 `/clear` 清空 transcript(旧横幅能在 `/clear` 后存活只因为它在 transcript 之外)。
|
||||
- 页脚左段变宽;窄终端上右侧状态段更早被裁剪。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定:`welcome` 未设置时无盒子角/产品标题、transcript 为空、模型在页脚;配置的欢迎语作为 transcript 第一行且无横幅;欢迎语在调色板切换的 transcript 重建后保留。PTY 冒烟测试以页脚模型名为启动标记并断言 `DEEPSEEK HARNESS` 不出现。快照验证完整帧。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-reload-command.md: de9a5502214a610d88024730b1c0c1044a396c92
|
||||
2026-07-21-tui-reload-command.zh.md: 25d1d448459221698ca63377f8f18d05a0fa3d21
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: The /reload command re-reads loader configs on demand
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-reload-command.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
HMR's file watcher only reacts to in-place `change` events under its configured roots (the config leaf's directory in the shipped demos). Editors that replace files by rename (BSD `sed -i`, `git checkout`) produce no event, and runtimes without the HMR entry (or without `--expose-internals`) have no config reload path at all. During development that means restarting the TUI to apply a config edit the watcher missed. Widening the watch roots to the whole repo was considered and rejected in discussion: dense package sharing makes module-level HMR a remount-most-of-the-tree operation with unpredictable externals boundaries.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`).
|
||||
|
||||
The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not `inject`): tests and embedders run the TUI without a Loader, where `/reload` degrades to a warning notice instead of failing the mount. Module-source hot reload stays watcher-owned; `/reload` refreshes configs only.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Widening the HMR watch roots to `packages/`/`apps/`.** Rejected for now: plugin-source changes reload every dependent plugin's fiber, and the repo's dense shared packages (`dsh-session`, `dsh-llm`, `dsh-tools`) make that a teardown of the spine and the UI mid-session — a restart in disguise with partial-reload hazards. A manual config-scope command captures the safe, predictable subset.
|
||||
|
||||
**Declaring `loader` in `inject`.** Rejected: it would make the Loader a hard dependency of the TUI, breaking every Loader-less composition (unit harness, embedders) for a dev convenience.
|
||||
|
||||
**A `cordis_reload` model-facing tool in dsh-tool-cordis.** Rejected: this is an operator action for the human at the terminal, not a capability the model should trigger; the cordis toolset's mount/unmount surface already covers the model's runtime-modification story.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `/reload` appears in the help line, autocomplete (marked EXPERIMENTAL (dev)), and the two help-rendering snapshots (re-recorded).
|
||||
- The command reports tree count and completion as transcript notices; per-file failures surface only in loader logs, which the TUI does not display — acceptable for a dev-only surface, noted in the completion message.
|
||||
- A re-entrancy guard serializes reloads: `/reload` while one is in flight is refused with a warning, keeping the loader's unmutexed tree-update pass single-writer; the guard releases on completion or failure.
|
||||
- `/reload` runs only while the agent is idle: a reload can dispose and re-mount entries, which under an active turn could tear tools or the adapter out from under in-flight calls. The check is advisory (a send can race in after it) but removes the common footgun.
|
||||
- If any `refresh()` rejects, the command reports the failure instead of leaving an unhandled rejection.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: a probe edit reloads successfully.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: /reload 命令按需重读 loader 配置
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-reload-command.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
HMR 的文件监听器只对其配置根目录(示例中即配置叶子所在目录)下的就地 `change` 事件起反应。以重命名方式替换文件的编辑器(BSD `sed -i`、`git checkout`)不产生事件,而没有挂载 HMR 配置项(或没有 `--expose-internals`)的运行时则完全没有配置重载路径。开发时这意味着监听器漏掉一次配置编辑就得重启 TUI。曾考虑把监听根目录扩大到整个仓库,讨论后否决:包之间的密集共享使模块级 HMR 变成「重挂大半棵树」的操作,externals 边界也不可预测。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较)。
|
||||
|
||||
TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`,而非 `inject`):测试和嵌入方在没有 Loader 的情况下运行 TUI,此时 `/reload` 退化为一条警告通知而不是挂载失败。模块源码热重载仍由监听器负责;`/reload` 只刷新配置。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把 HMR 监听根目录扩大到 `packages/`/`apps/`。** 暂缓否决:插件源码变更会重载每个依赖插件的 fiber,而仓库中密集共享的包(`dsh-session`、`dsh-llm`、`dsh-tools`)使其等同于会话中途拆掉主干和 UI——伪装成热重载的重启,还带部分重载的隐患。手动的、只覆盖配置范围的命令抓住了安全、可预测的那个子集。
|
||||
|
||||
**在 `inject` 中声明 `loader`。** 否决:那会让 Loader 成为 TUI 的硬依赖,为了一个开发便利破坏所有无 Loader 的组合(单元测试 harness、嵌入方)。
|
||||
|
||||
**在 dsh-tool-cordis 里做一个面向模型的 `cordis_reload` 工具。** 否决:这是终端前人类操作者的动作,不是模型应当触发的能力;cordis 工具集的 mount/unmount 表面已经覆盖模型的运行时修改需求。
|
||||
|
||||
## Consequences
|
||||
|
||||
- `/reload` 出现在帮助行、自动补全(标注 EXPERIMENTAL (dev))和两个渲染帮助的快照中(已重新录制)。
|
||||
- 命令以 transcript 通知报告树数量与完成;单文件失败只出现在 loader 日志里,TUI 不显示——对仅供开发的表面可以接受,完成消息中已注明。
|
||||
- 重入保护串行化重载:前一次进行中时 `/reload` 会被拒绝并提示警告,使 loader 无互斥的树更新过程保持单写者;保护在完成或失败时释放。
|
||||
- `/reload` 只在 agent 空闲时运行:重载可能卸载并重新挂载配置项,在活跃轮次下这会把工具或适配器从进行中的调用脚下抽掉。检查是建议性的(检查后仍可能有 send 竞争进来),但消除了常见的坑。
|
||||
- 任一 `refresh()` 若 reject,命令会报告失败而不是留下未处理的 rejection。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑后 reload 成功生效。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-resume-command.md: 2282eaa9bff83fdb75bdce315d6b17bf8f9ea303
|
||||
2026-07-21-tui-resume-command.zh.md: f9d989a5b4e7eb106ff21c5a4fcfa770a5962343
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Resume command hint and `/resume`
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-resume-command.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The TUI can resume a session by launch (`RESUME_SESSION_ID=<id> dsh` feeding `dsh-tui-demo`'s `resumeSessionId`), but nothing told the user the command. On exit the session id survived only in the log and `./.sessions` filenames — the [no-banner Agent Note](2026-07-21-tui-no-banner.md) removed the last place it was shown — so resuming meant hunting for the id and reconstructing the invocation. There was also no in-session way to see which sessions in this workspace are resumable.
|
||||
|
||||
## Decision
|
||||
|
||||
A single optional `resumeCommand` config field on `dsh-tui` gates both surfaces: a shell command template whose every `{session}` is replaced with the live session id (e.g. `dsh --resume {session}`). Absent, neither surface appears.
|
||||
|
||||
- **Exit hint.** Process-exiting shutdown prints `To resume this session: <command>` (muted label) via `runtime.terminal.write` after `ui.stop()`, before `runtime.exit`. It prints only once the session is durably persisted: `currentResumeCommand()` scans the session list for the current id and returns `undefined` if it is absent, so a session abandoned before its first flush advertises no command that would fail to load.
|
||||
- **`/resume`.** Lists this workspace's persisted sessions newest-first, each with its resume command, marking the current one `(current)`. It warns when `resumeCommand` is unconfigured or no persistence backend is mounted, and notes when nothing is persisted yet. The listing is asynchronous, so the transcript updates a tick after submit.
|
||||
- **Listing.** `listWorkspaceSessions()` reads the optional `sessionPersistence` service's `list()`, keeps headers whose `cwd === agent.session.header.cwd`, and sorts by `createdAt` descending. A `list()` rejection is swallowed to `[]` — a persistence failure must never block terminal exit or crash `/resume`.
|
||||
|
||||
`sessionPersistence` is an optional injected service reached through `ctx.get('sessionPersistence')` (not `inject`), declared as an optional peer dependency. Without a backend the field still parses; the exit hint and `/resume` degrade to nothing and the unconfigured/no-backend warnings respectively. `dsh-tui-demo` forwards `resumeCommand` to `dsh-tui`, and the runnable `examples/tui-agent` leaves set `dsh --resume {session}`. The `dsh` CLI (`apps/cli`) parses that `--resume <id>` flag through `parseResumeArg` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md), setting `RESUME_SESSION_ID` before boot so the printed command runs back through the config's existing `resumeSessionId` intake; a mistyped or repeated flag fails loud rather than silently starting fresh.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Hardcode or auto-detect the resume invocation.** Rejected: the launch command is deployment-specific — the env-var name, binary, and flags all vary — so a `DEFAULT_*` constant would be a fixed tunable, not configurability. A template owned by the leaf keeps the choice where the deployment lives, and `{session}` is the only substitution the TUI must know.
|
||||
|
||||
**Two config fields, one per surface.** Rejected: both render the identical command, so one field keeps them symmetric and unable to drift; there is no deployment that wants the hint but not the listing.
|
||||
|
||||
**Print the exit hint unconditionally.** Rejected: resuming a session id that never flushed fails to load, so advertising it is a broken instruction. Gating on the id appearing in `list()` costs one scan and only ever suppresses a dead command.
|
||||
|
||||
**Resume in place from `/resume` (relaunch or reattach).** Rejected: the TUI does not own agent lifecycle or process spawning ([front-door Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md)). Printing a copyable command respects that boundary and matches the `pi --resume` affordance the request cited.
|
||||
|
||||
**Make `sessionPersistence` a required `inject`.** Rejected: the TUI must run without persistence (fixtures, ephemeral runs). An optional service that degrades preserves that, and matches the [`session-query`](../../../../packages/session-query/session-query/package.json) precedent for the same optional peer.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `dsh-tui` gains an optional peer dependency on `@deepseek-ai/dsh-session-persistence` (`peerDependenciesMeta.optional`), matching `session-query`; the package still loads and passes its coverage gate without a backend mounted.
|
||||
- The help line and autocomplete gain `/resume`; two existing snapshots re-recorded for the wider help line, and a new `resume-sessions` checkpoint pins the rendered listing.
|
||||
- `dsh-tui-demo` and both `examples/tui-agent` leaves carry `resumeCommand`, so a real TUI run now prints its own resume command on exit, and the `dsh` CLI accepts the printed `--resume <id>` flag to run it.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins the seven behaviors: the exit hint prints only when the current session is persisted, is omitted when it is not and when `list()` rejects; `/resume` lists workspace sessions newest-first with the `(current)` marker and cwd filter, warns when unconfigured and when no backend is mounted, and notes when nothing is persisted. The `resume-sessions` snapshot verifies the full rendered frame. The harness provides a fake `sessionPersistence` through `ctx.provide`. For the `--resume` flag, `packages/ui/app-boot/tests/app-boot.spec.ts` pins `parseResumeArg` (space and inline forms, position independence, and the fail-loud on a valueless, empty, or repeated flag), and `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots `apps/cli` with `--resume <missing-id>` and asserts the config resume fails loud — proving the flag reaches the `resumeSessionId` intake.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Agent Note: Resume command hint and `/resume`
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-resume-command.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
TUI 本就能通过启动参数恢复会话(`RESUME_SESSION_ID=<id> dsh` 喂给 `dsh-tui-demo` 的 `resumeSessionId`),但没有任何地方告诉用户这条命令。退出时会话 id 只残留在会话日志和 `./.sessions` 文件名里——[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md) 移除了它最后一处显示位置——因此恢复意味着先翻出 id 再拼回调用命令。也没有任何会话内的方式查看当前 workspace 里哪些会话可恢复。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-tui` 上一个可选的 `resumeCommand` 配置字段同时管辖两处出口:一个 shell 命令模板,其中每一处 `{session}` 都会被替换为当前会话 id(例如 `dsh --resume {session}`)。未设置时两处都不出现。
|
||||
|
||||
- **退出提示。** 以退出进程方式关闭时,在 `ui.stop()` 之后、`runtime.exit` 之前,经由 `runtime.terminal.write` 打印 `To resume this session: <command>`(弱化的标签)。仅当会话已持久化时才打印:`currentResumeCommand()` 在会话列表中查找当前 id,若不存在则返回 `undefined`,因此在首次刷盘前就被放弃的会话不会宣传一条注定加载失败的命令。
|
||||
- **`/resume`。** 按最新在前列出当前 workspace 里已持久化的会话,每条附带其恢复命令,并给当前会话标注 `(current)`。当 `resumeCommand` 未配置或未挂载持久化后端时给出告警,尚无任何会话被持久化时给出提示。列出是异步的,因此提交后文本记录会在下一个 tick 更新。
|
||||
- **列出逻辑。** `listWorkspaceSessions()` 读取可选的 `sessionPersistence` 服务的 `list()`,保留 `cwd === agent.session.header.cwd` 的头部,并按 `createdAt` 降序排序。`list()` 拒绝时吞掉为 `[]`——持久化失败绝不能阻塞终端退出或让 `/resume` 崩溃。
|
||||
|
||||
`sessionPersistence` 是一个通过 `ctx.get('sessionPersistence')`(而非 `inject`)获取的可选注入服务,声明为可选的对等依赖(peer dependency)。没有后端时该字段仍能解析;退出提示与 `/resume` 分别退化为不做任何事、以及给出未配置/无后端告警。`dsh-tui-demo` 将 `resumeCommand` 转发给 `dsh-tui`,可运行的 `examples/tui-agent` 叶子配置设为 `dsh --resume {session}`。`dsh` CLI(`apps/cli`)通过 [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `parseResumeArg` 解析该 `--resume <id>` 标志,在启动前设置 `RESUME_SESSION_ID`,因此打印出的命令会重新走回配置中既有的 `resumeSessionId` 入口;拼写错误或重复的标志会直接报错退出,而非悄悄开启一个新会话。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**硬编码或自动探测恢复调用命令。** 否决:启动命令与部署强相关——环境变量名、可执行文件、参数都各不相同——因此一个 `DEFAULT_*` 常量只会是固定的可调项,而非可配置项。由叶子拥有的模板把这个选择留在部署所在之处,而 `{session}` 是 TUI 唯一需要知道的替换。
|
||||
|
||||
**两个配置字段,每处出口一个。** 否决:两处渲染的是完全相同的命令,因此单个字段让它们保持对称、不会漂移;不存在只想要提示而不想要列表的部署。
|
||||
|
||||
**无条件打印退出提示。** 否决:恢复一个从未刷盘的会话 id 会加载失败,宣传它就是一条错误指令。以 id 是否出现在 `list()` 中为条件仅需一次扫描,且只会抑制一条注定失败的命令。
|
||||
|
||||
**从 `/resume` 就地恢复(重启或重连)。** 否决:TUI 不拥有 agent 生命周期或进程创建([全屏 TUI 门面 Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md))。打印一条可复制的命令尊重这条边界,也契合需求所引用的 `pi --resume` 用法。
|
||||
|
||||
**把 `sessionPersistence` 设为必需的 `inject`。** 否决:TUI 必须能在无持久化时运行(fixture(测试前置数据)、临时运行)。一个会优雅退化的可选服务保住了这一点,也与 [`session-query`](../../../../packages/session-query/session-query/package.json) 对同一可选对等依赖的先例一致。
|
||||
|
||||
## Consequences
|
||||
|
||||
- `dsh-tui` 新增对 `@deepseek-ai/dsh-session-persistence` 的可选对等依赖(`peerDependenciesMeta.optional`),与 `session-query` 一致;未挂载后端时该包仍能加载并通过其覆盖率门禁。
|
||||
- 帮助行和自动补全新增 `/resume`;两个既有快照因帮助行变宽而重新录制,新增的 `resume-sessions` 检查点固定渲染出的列表。
|
||||
- `dsh-tui-demo` 及两个 `examples/tui-agent` 叶子配置都带上 `resumeCommand`,因此真实的 TUI 运行现在退出时会打印自己的恢复命令,且 `dsh` CLI 接受打印出的 `--resume <id>` 标志来运行它。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定这七种行为:退出提示仅在当前会话已持久化时打印,未持久化时以及 `list()` 拒绝时都不打印;`/resume` 按最新在前列出 workspace 会话并带 `(current)` 标注与 cwd 过滤、未配置时告警、无后端时告警、尚无持久化时给出提示。`resume-sessions` 快照验证完整渲染帧。测试脚手架通过 `ctx.provide` 提供一个假的 `sessionPersistence`。对于 `--resume` 标志,`packages/ui/app-boot/tests/app-boot.spec.ts` 固定 `parseResumeArg`(空格形式与内联形式、位置无关性,以及在标志缺值、为空或重复时直接报错退出),`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 用 `--resume <missing-id>` 启动 `apps/cli` 并断言配置恢复直接报错退出——证明该标志抵达了 `resumeSessionId` 入口。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-skill-slash-command.md: d7532a05fce5605491ce42c87a2a523eb4c19acc
|
||||
2026-07-21-tui-skill-slash-command.zh.md: 16930020bd404f7bc9476169cd1d063aa57b5c94
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: TUI skill slash command
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-skill-slash-command.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [skill system](2026-07-05-skill-system.md) shipped with model-initiated loading as its only path: the `skill({ name })` tool lets the model pull a skill body into a turn, but a person driving the TUI could not load a skill on demand. Other coding agents expose a `/skill:<name>` slash command for exactly this — the user, not the model, decides a task matches a skill and injects its instructions. The skill-system note listed direct user invocation as deferred work, and the interactive front door is where it belongs.
|
||||
|
||||
## Decision
|
||||
|
||||
The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill:<name> [instructions]` command. On submit it loads the named skill and delivers one text block as a user turn — sent with `agent.send()` while idle and `agent.steer()` while running, the same rule as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `<skill name="…">` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool and changes no skill-system package contract.
|
||||
|
||||
The TUI reads the skill service through `ctx.get('skills')`, not a declared injection, because skills mount conditionally: a deployment without the registry keeps a working front door, and `/skill:` there reports that skills are unavailable rather than failing to mount. `createTuiChat` is synchronous while `ctx.skills.list()` is async, so autocomplete seeds the static slash commands immediately and rebuilds the provider with `skill:<name>` entries once the catalog resolves; a resolution that arrives after disposal is dropped, and a rejected lookup keeps the base commands.
|
||||
|
||||
Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything.
|
||||
|
||||
`renderSkillInvocation` and the resource-base line are the TUI's own, deliberately not reused from `dsh-tool-skill`'s `skill` tool result. The tool wraps a body in `<skill_content>`/`<skill_resources>`/`<skill_instructions>` for a *tool result*; a manual invocation is a *user turn*, and coupling the two renderers would force one model-facing shape to serve both surfaces. The cost is two renderers that both format a skill body; the benefit is that each surface's model-facing text evolves independently, and each is pinned where it is produced.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Add a `user-invocable` frontmatter field and enforce it in the registry.** Rejected for this change. The skill-system note defers that field, and manual invocation does not need it: the TUI is a trusted local caller, so `get()` already authorizes loading any skill, and autocomplete visibility keys off the existing `disableModelInvocation`. A new per-skill field would add a contract to the registry, local provider, and tool with no current consumer beyond visibility, which `disableModelInvocation` already covers.
|
||||
|
||||
**Declare `skills` as a TUI injection.** Rejected because skills mount conditionally; a declared injection would make the front door require the registry and refuse to mount without it, contradicting the package's optional-service stance. `ctx.get('skills')` reads the global store and tolerates absence.
|
||||
|
||||
**Reuse `dsh-tool-skill`'s renderer.** Rejected because its output is a tool-result shape (`<skill_content>` and siblings) written for the model's tool channel, while a slash invocation is a user message. Sharing it would either leak tool-result vocabulary into a user turn or fork the shared renderer on a `surface` flag — more coupling than two small formatters.
|
||||
|
||||
**Route submissions through the model's `skill` tool.** Rejected because the user has already decided; a tool call would spend a model round-trip to fetch a body the front door can load directly, and would not work while the agent is mid-turn.
|
||||
|
||||
## Consequences
|
||||
|
||||
Manual invocation always reloads the full skill body: the TUI does not detect a skill already present in the conversation, so a repeated `/skill:` appends its instructions again — acceptable because re-injection is sometimes the intent, and documented under the package README's Known Limitations. The two-renderer duplication is a standing maintenance cost accepted above. The `<skill name="…">` wrapper is stable model-visible text and is pinned verbatim in unit tests against a real `SkillService`; the help-panel line is pinned by the `errors-and-help` terminal snapshot. Autocomplete population and the disposed-lookup and failed-lookup branches are covered by unit tests that mount the real registry or a controllable service. End-to-end delivery is proven by a dedicated real-composition test: the `examples/tui-agent` keyless PTY smoke (`tui-keyless-smoke.e2e.ts`) boots the production TUI/agent/skill stack through the Loader under a genuine pseudo-terminal with only the model scripted, drops a fixture skill under the agents-home `skills/` root, types `/skill:<name>` as live keystrokes, and asserts the scripted adapter echoes the fixture's body marker only when the rendered `<skill>` block arrives — exercising `ctx.get('skills')` resolution in the shipped tree, the client-side parse, the local provider load, and the user turn reaching the model together. That fixture's frontmatter description avoids a `: ` colon-space so its YAML stays a plain scalar; an invalid-frontmatter skill is silently dropped during discovery.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: TUI skill slash command
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-skill-slash-command.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
[skill 系统](2026-07-05-skill-system.md)交付时只有模型发起加载这一条路径:`skill({ name })` 工具让模型把某个 skill 正文拉进一个轮次,但操作 TUI 的人无法按需加载 skill。其他编码 agent(智能体)正是为此提供了 `/skill:<name>` 斜杠命令——由用户而非模型判断某个任务与某个 skill 匹配,并注入其指令。skill 系统 note 把直接的用户发起调用列为待办工作,而交互式前门正是它该落地的地方。
|
||||
|
||||
## Decision
|
||||
|
||||
[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill:<name> [instructions]` 命令。提交时它加载指定的 skill,并投递一个文本块作为用户轮次——空闲时用 `agent.send()` 发送、运行中用 `agent.steer()` 中途引导,与普通编辑器输入遵循同一规则。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `<skill name="…">` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的能力;它不新增任何面向模型的工具,也不改动任何 skill 系统包的契约。
|
||||
|
||||
TUI 通过 `ctx.get('skills')` 读取 skill 服务,而非声明式注入,因为 skill 是条件挂载的:没有注册表的部署仍保有可用的前门,此时 `/skill:` 会报告 skill 不可用,而不是挂载失败。`createTuiChat` 是同步的,而 `ctx.skills.list()` 是异步的,所以自动补全先立即种入静态斜杠命令,待目录解析完成后再用 `skill:<name>` 条目重建 provider(提供方);在 dispose(资源释放)之后才到达的解析结果会被丢弃,而被拒绝的查找会保留基础命令。
|
||||
|
||||
自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。
|
||||
|
||||
`renderSkillInvocation` 及资源基址行是 TUI 自有的,刻意不复用 `dsh-tool-skill` 的 `skill` 工具结果。该工具把正文包进 `<skill_content>`/`<skill_resources>`/`<skill_instructions>` 是为了一个*工具结果*;而手动调用是一个*用户轮次*,把两个渲染器耦合起来会迫使一种面向模型的形态同时服务两个界面。代价是两个都在格式化 skill 正文的渲染器;收益是各界面面向模型的文本可以独立演进,且各自在其产出处被固定。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**新增 `user-invocable` frontmatter 字段并在注册表中强制执行。** 本次改动否决。skill 系统 note 把该字段列为待办,而手动调用并不需要它:TUI 是可信的本地调用方,`get()` 已经授权加载任意 skill,自动补全的可见性以既有的 `disableModelInvocation` 为准。新增一个逐 skill 字段会给注册表、本地提供方和工具都加上一条契约,而除了可见性之外没有任何现有消费方,可见性又已由 `disableModelInvocation` 覆盖。
|
||||
|
||||
**把 `skills` 声明为 TUI 注入。** 否决,因为 skill 是条件挂载的;声明式注入会使前门必须依赖注册表,缺少它就拒绝挂载,与本包可选服务的立场相悖。`ctx.get('skills')` 读取全局存储并容忍其缺失。
|
||||
|
||||
**复用 `dsh-tool-skill` 的渲染器。** 否决,因为它的输出是为模型的工具通道所写的工具结果形态(`<skill_content>` 及其同类),而斜杠调用是一条用户消息。共用它要么把工具结果词汇泄漏进用户轮次,要么按 `surface` 标志分叉共享渲染器——比两个小格式化器耦合更重。
|
||||
|
||||
**让提交经由模型的 `skill` 工具。** 否决,因为用户已经作出了判断;一次工具调用会花掉一个模型往返去取一份前门可以直接加载的正文,而且在 agent 处于轮次中途时也无法工作。
|
||||
|
||||
## Consequences
|
||||
|
||||
手动调用总是重新加载完整的 skill 正文:TUI 不会检测某个 skill 是否已在对话中出现,因此重复的 `/skill:` 会再次追加其指令——这可以接受,因为重新注入有时正是意图所在,且已在本包 README 的已知限制中说明。上文接受的双渲染器重复是一项长期维护成本。`<skill name="…">` 包裹是稳定的、模型可见的文本,并在单元测试中针对一个真实的 `SkillService` 逐字固定;帮助面板那一行由 `errors-and-help` 终端快照固定。自动补全的填充、dispose 后查找分支、以及查找失败分支,都由挂载真实注册表或可控服务的单元测试覆盖。端到端的投递由一项专门的真实组合测试证明:`examples/tui-agent` 的无密钥 PTY 冒烟测试(`tui-keyless-smoke.e2e.ts`)在真实伪终端下经由 loader 引导生产环境的 TUI/agent/skill 栈,仅对模型进行脚本化,把一个夹具 skill 放入 agents home 的 `skills/` 根下,以真实按键输入 `/skill:<name>`,并断言:只有当渲染出的 `<skill>` 文本块抵达时,脚本化适配器才会回显该夹具的正文标记——从而一并演练了 `ctx.get('skills')` 在发布树中的解析、客户端解析、本地 provider 的加载,以及用户回合抵达模型。该夹具的 frontmatter 描述避免出现 `: ` 冒号加空格,使其 YAML 保持为纯标量;frontmatter 无效的 skill 会在发现阶段被静默丢弃。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-steering-queue-badge.md: b29a4667e778e65b0678f946fcaa34b79c4d7da0
|
||||
2026-07-21-tui-steering-queue-badge.zh.md: 4bfce461e11bce1773d6e0b15aabecf6a6a6144c
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: TUI status line badges queued steering messages
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-steering-queue-badge.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
While a turn runs, an editor submission calls `agent.steer()` and joins the steering queue behind the running turn ([front-door Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md)). The running status line ended only with the `Enter sends steering, Esc cancels` hint, so pressing Enter gave no feedback that the message landed or how many were waiting to reach the model. A user steering several times could not tell the queue from a dropped keystroke.
|
||||
|
||||
## Decision
|
||||
|
||||
The agent's inbox is the authoritative steering queue but is not observable from the TUI, so the badge is a live count reconstructed from the public `agent/queued` and `steering/message` events rather than a projection of the queue itself.
|
||||
|
||||
- The running status line composes through `formatTurnStatus`, which inserts a `${queued} queued · ` badge before the `Enter sends steering, Esc cancels` hint when `queued > 0` and shows the plain hint at zero; the phase label and elapsed timing before it are the [verbose status line](2026-07-21-tui-verbose-status-line.md)'s.
|
||||
- `createTuiChat` owns a `pendingSteering` counter: `+1` on each `agent/queued` for this agent whose `info.steering` is set, `-1` (floored at zero) on each `steering/message` session event as the loop drains one, and reset to zero whenever the agent leaves `running`.
|
||||
- The count refreshes onto the live `Loader` through `setMessage`; the refresh is a no-op while idle because the loader exists only during a running turn.
|
||||
- The reset lives in the `agent/status` transition, not in `setStatus`, because `setStatus` also runs on mid-turn palette changes and must not clear a live count.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Derive the count from the session log alone** (enqueued minus drained, recomputed on replay). Rejected: a cancellation clears the inbox without logging a drain, so the log cannot distinguish a drained message from a discarded one; the reset-on-non-running anchor is simpler and self-correcting each turn.
|
||||
|
||||
**Reset inside `setStatus`.** Rejected: `setStatus` re-runs on `applyColorScheme` mid-turn, which would wrongly zero a live count; the status transition is the only place a turn actually ends.
|
||||
|
||||
**Drop the decrement clamp.** Rejected: loop-authored steering (e.g. continuation reasons) logs `steering/message` with no matching user-queued increment, which would drive the count negative; the zero floor keeps the badge a lower bound rather than a lie.
|
||||
|
||||
**Make the wording or a threshold configurable.** Rejected: the no-hardcoded-tunables rule targets deployment-varying behavior, not brand copy; the `welcome`/hint strings are already fixed presentation.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The badge is best-effort live UI state, not a logged surface: it is rebuilt from events and reset each turn, never persisted, so a resumed running turn starts its badge from zero.
|
||||
- A cancellation mid-queue clears the badge cleanly through the non-running reset, and a drain past zero is a no-op — neither can strand a stale count.
|
||||
- A loop continuation that keeps the agent `running` while re-enqueuing undrained late steering can transiently over-count until the next idle reset; the badge is advisory, so the window is acceptable.
|
||||
- `packages/ui/tui/src/index.ts` stays at 100 % per-file coverage.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` drives the running status frame through the real `createTuiChat`: the plain hint at zero, a foreign-agent queue ignored, the increment to `2 queued`, a non-steering queue left untouched, the decrement as each message drains, the clamp on a drain past zero, and the reset when the turn ends. Verified live in tmux — the badge showed `3 queued` after three `agent.steer()` calls, then `1 queued` as two drained.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: TUI 状态行标示排队中的 steering 消息
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-steering-queue-badge.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
轮次运行期间,编辑器提交会调用 `agent.steer()`,在运行中的轮次后面加入 steering(中途引导)队列([前门 Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md))。运行时的状态行只以 `Enter sends steering, Esc cancels` 提示收尾,因此按下 Enter 后没有任何反馈表明消息已入队、也看不出有多少条正在等待送达模型。连续 steering 多次的用户无法把队列和被吞掉的按键区分开。
|
||||
|
||||
## Decision
|
||||
|
||||
agent(智能体)的收件箱(inbox)才是权威的 steering 队列,但 TUI 无法观测它,因此徽标是从公开的 `agent/queued` 与 `steering/message` 事件重建出的实时计数,而非对队列本身的投影。
|
||||
|
||||
- 运行时的状态行经 `formatTurnStatus` 组装:`queued > 0` 时在 `Enter sends steering, Esc cancels` 提示前插入 `${queued} queued · ` 徽标,为零时是纯提示文本;其前的阶段标签与耗时归[详细状态行](2026-07-21-tui-verbose-status-line.md)所有。
|
||||
- `createTuiChat` 持有一个 `pendingSteering` 计数器:每收到一个针对本 agent 且 `info.steering` 为真的 `agent/queued` 就 `+1`,agent loop(智能体循环)每排空一条时随对应的 `steering/message` 会话事件 `-1`(下限为零),agent 一旦离开 `running` 状态即重置为零。
|
||||
- 计数通过 `setMessage` 刷新到实时的 `Loader` 上;空闲时刷新是空操作,因为 loader 只在运行中的轮次期间存在。
|
||||
- 重置放在 `agent/status` 状态切换里,而非 `setStatus` 中,因为 `setStatus` 在轮次中途的颜色方案变化时也会运行,绝不能清掉一个实时计数。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**仅从会话日志推导计数**(入队数减去排空数,回放时重算)。否决:取消会清空 inbox 而不记录排空,因此日志无法区分一条消息是被排空还是被丢弃;「离开运行态即重置」这个锚点更简单,且每轮自我校正。
|
||||
|
||||
**在 `setStatus` 内重置。** 否决:`setStatus` 会在轮次中途的 `applyColorScheme` 时重新运行,会错误地把实时计数清零;状态切换才是轮次真正结束的唯一位置。
|
||||
|
||||
**去掉递减的下限钳制。** 否决:agent loop 自行产生的 steering(如 continuation 续跑原因)会记录 `steering/message`,却没有对应的用户入队递增,这会把计数压到负数;零下限让徽标成为下界,而非谎报。
|
||||
|
||||
**把措辞或某个阈值做成配置。** 否决:「插件里不许硬编码可调参数」规则针对的是随部署变化的行为,不是品牌文案;`welcome`/提示字符串本就是固定的展示文案。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 徽标是尽力而为的实时 UI 状态,不写入日志:它由事件重建、每轮重置、从不持久化,因此恢复(resume)出的运行中轮次徽标从零开始。
|
||||
- 队列中途取消会经由「离开运行态即重置」干净地清掉徽标,排空到零以下则是空操作——两者都不会残留一个陈旧计数。
|
||||
- 如果 agent loop 续跑时让 agent 保持 `running`、同时把未排空的迟到 steering 重新入队,则可能短暂多计,直到下一次空闲重置;徽标只作参考,因此这个窗口可以接受。
|
||||
- `packages/ui/tui/src/index.ts` 保持 100% 的单文件覆盖率。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 通过真实的 `createTuiChat` 驱动运行时状态帧:为零时的纯提示、忽略他方 agent 的入队、递增到 `2 queued`、非 steering 的入队保持不变、每条消息排空时的递减、排空到零以下时的钳制、以及轮次结束时的重置。已在 tmux 中实机验证——三次 `agent.steer()` 调用后徽标显示 `3 queued`,随后两条排空时显示 `1 queued`。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-verbose-status-line.md: f277afd3a874b30a29dc0ef193740f636d22290b
|
||||
2026-07-21-tui-verbose-status-line.zh.md: 9fa7cf29c67245382bbee6b72f2710c5550d7f54
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: The running status line shows the turn phase and elapsed time
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-verbose-status-line.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
While a turn ran, the [full-screen TUI](2026-07-17-dedicated-full-screen-tui-front-door.md) showed a single static "Working" spinner. It conveyed neither how long the current step had taken nor what the agent was doing — waiting on the model, thinking, streaming a response, or running tools — so a slow or stalled turn was indistinguishable from a fast one.
|
||||
|
||||
## Decision
|
||||
|
||||
- While a turn runs, the status line above the editor shows a derived phase label with elapsed time, keeping the trailing `— Enter sends steering, Esc cancels` hint. The four phases and their labels are `waiting` → "Waiting for the first token", `thinking` → "Thinking", `responding` → "Responding", and `executing` → "Executing tools".
|
||||
- The phase is presentation state the TUI derives from live session events, not a session event or agent status of its own. `step/start` enters `waiting`; an `assistant/chunk` reasoning delta or reasoning block-start enters `thinking`; a text delta or text block-start enters `responding`; a `tool/call` enters `executing`. The event map is merge-extensible, so every other event kind falls through a default and leaves the phase unchanged.
|
||||
- The label reports two clocks — `<phase> <phase-elapsed> · total <step-elapsed>` — except `waiting`, which shows only the step total. The phase clock resets on a genuine phase change or a new step; the step clock resets on `step/start`. Durations format as `8s` below a minute and `1m05s` at or above one. Tool time between `step/end` and the next `step/start` accrues to the finishing step's total.
|
||||
- A single `RunningStatus` controller — the loader, the phase, the two baselines, and a refresh timer — exists only while a turn runs. A one-second `setInterval` refreshes the elapsed time; a phase event refreshes it immediately. `clearStatus` clears the interval, stops the loader, and drops the controller, so any transition to idle or disposed leaves no live timer, matching the [banner sweep](2026-07-21-tui-banner-sweep.md)'s timer hygiene. A mid-turn palette rebuild (`setStatus` re-derives the editor border on a terminal color-scheme change) carries the phase and both baselines across, so a running status never snaps back to `waiting`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Emit the phase as a session event or agent status.** Rejected: the phase is a presentation detail the TUI reconstructs from events already logged. A durable, model-visible phase would demand a new session event under the model-visible ⟺ logged rule, for no model benefit.
|
||||
|
||||
**Reuse pi-tui's `Loader` animation timer to refresh the elapsed text.** Not available: the vendored `Loader` animates only its spinner glyph, and its dist is not ours to change. The TUI owns a separate one-second interval, cleared on teardown.
|
||||
|
||||
**Infer the phase from tool-drain or streaming-component state.** Rejected: the `step/start`, `assistant/chunk`, and `tool/call` lifecycle events are cleaner signals, already handled in the same live listener, and avoid coupling the status line to other components.
|
||||
|
||||
**Show only elapsed time, or only the phase.** Rejected: both are wanted — the per-phase time answers what the agent is doing, the per-step total answers how long the step has taken.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The status line reads, for example, `Thinking 4s · total 8s — Enter sends steering, Esc cancels`, so the agent's current activity and step duration are legible and a stall is visible.
|
||||
- Phase detection is best-effort presentation: an unhandled future chunk or event kind leaves the last phase in place and never throws.
|
||||
- Exactly one `setInterval` runs per active turn, cleared with the controller on every idle or disposed transition and on shutdown.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins each phase label against its triggering event (`step/start`, reasoning and text deltas and block-starts, `tool/call`), that a new step reopens the wait window, that the elapsed time advances on the controller's own timer past one second, that a step beyond a minute renders `1m…`, that a mid-turn color-scheme change preserves the phase and elapsed time, and that a live event arriving before the turn runs moves no status. Verified live in tmux.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: 运行状态行展示轮次阶段与已用时长
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-verbose-status-line.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
在轮次运行期间,[全屏 TUI](2026-07-17-dedicated-full-screen-tui-front-door.md) 只显示一个静态的 "Working" loader 动画。它既不表明当前步骤已耗时多久,也不表明 agent(智能体)正在做什么——等待模型、思考、流式输出回复,还是运行工具——因此运行缓慢或卡住的轮次与运行很快的轮次无从区分。
|
||||
|
||||
## 决策
|
||||
|
||||
- 轮次运行期间,编辑器上方的状态行显示一个派生的阶段标签及已用时长,并保留末尾的 `— Enter sends steering, Esc cancels` 提示。四个阶段及其标签为 `waiting` → "Waiting for the first token"、`thinking` → "Thinking"、`responding` → "Responding"、`executing` → "Executing tools"。
|
||||
- 阶段是 TUI 从实时会话事件派生出的呈现状态,而非它自有的会话事件或 agent 状态。`step/start` 进入 `waiting`;`assistant/chunk` 的 reasoning 分片或 reasoning 块开始(`block-start`)进入 `thinking`;text 分片或 text 块开始进入 `responding`;`tool/call` 进入 `executing`。该事件映射可合并扩展,因此其余任何事件类型都落入默认分支,保持阶段不变。
|
||||
- 标签汇报两个时钟——`<phase> <phase-elapsed> · total <step-elapsed>`——但 `waiting` 只显示步骤总时长。阶段时钟在真正发生阶段切换或进入新步骤时重置;步骤时钟在 `step/start` 时重置。时长在不足一分钟时格式化为 `8s`,达到或超过一分钟时格式化为 `1m05s`。`step/end` 与下一个 `step/start` 之间的工具时间计入结束步骤的总时长。
|
||||
- 单一的 `RunningStatus` 控制器——loader、阶段、两个基准时刻以及一个刷新定时器——仅在轮次运行期间存在。一个每秒触发的 `setInterval` 刷新已用时长;阶段事件则立即刷新。`clearStatus` 清除该 interval、停止 loader 并丢弃控制器,因此任何向 idle 或 disposed 的转变都不会遗留活动定时器,与 [banner 扫入动画](2026-07-21-tui-banner-sweep.md)的定时器清理保持一致。轮次进行中的调色板重建(终端颜色方案变化时 `setStatus` 会重新派生编辑器边框)会将阶段与两个基准时刻一并沿用过来,因此运行中的状态绝不会退回 `waiting`。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**将阶段作为会话事件或 agent 状态发出。** 已否决:阶段是 TUI 从已记录事件重建出的呈现细节。一个持久、模型可见的阶段会依据 model-visible ⟺ logged 规则要求新增一个会话事件,而对模型没有任何好处。
|
||||
|
||||
**复用 pi-tui 的 `Loader` 动画定时器来刷新已用时长文本。** 不可行:`Loader` 是 vendored 依赖,只驱动其加载动画字形,其 dist 不归我们改动。TUI 自持一个独立的每秒 interval,并在拆卸时清除。
|
||||
|
||||
**从工具耗尽或流式组件状态推断阶段。** 已否决:`step/start`、`assistant/chunk` 和 `tool/call` 这些生命周期事件是更干净的信号,已在同一个实时监听器中处理,且避免让状态行与其他组件耦合。
|
||||
|
||||
**只显示已用时长,或只显示阶段。** 已否决:两者都需要——按阶段的时长回答 agent 在做什么,按步骤的总时长回答该步骤已耗时多久。
|
||||
|
||||
## 后果
|
||||
|
||||
- 状态行例如显示 `Thinking 4s · total 8s — Enter sends steering, Esc cancels`,从而 agent 的当前活动与步骤时长一目了然,卡顿也随之可见。
|
||||
- 阶段检测是尽力而为的呈现:未处理的未来分片或事件类型会保持上一个阶段不变,绝不抛错。
|
||||
- 每个活动轮次恰好运行一个 `setInterval`,在每次向 idle 或 disposed 的转变以及关停时随控制器一并清除。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 针对触发事件锁定每个阶段标签(`step/start`、reasoning 与 text 的分片及块开始、`tool/call`),并锁定新步骤会重新开启等待窗口、已用时长在控制器自有定时器上超过一秒后递增、超过一分钟的步骤渲染为 `1m…`、轮次进行中的颜色方案变化会保留阶段与已用时长,以及轮次开始前到达的实时事件不移动任何状态。已在 tmux 中实机验证。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-web-bind-address.md: 3332176c0cee940648ad334a44edd30879225503
|
||||
2026-07-22-web-bind-address.zh.md: f539fff93628205bf0099d8f23dfd13d14e55ca5
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Explicit web bind address
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-web-bind-address.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh web` binds every network interface even when its browser runs on the same machine. Local use therefore exposes an unauthenticated development server without an explicit operator choice, while remote-container and LAN-browser use still needs a supported way to accept non-loopback connections.
|
||||
|
||||
The HTTP carrier also hides the bind address inside `startWebServer()`, so alternate shells cannot state their own network policy at the package boundary.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh web` binds `127.0.0.1` by default. The CLI accepts `--host 0.0.0.0` as the explicit all-interface mode and rejects other values so its network modes remain a small, deliberate contract. All-interface mode keeps printing the loopback URL and, when available, the first external IPv4 URL.
|
||||
|
||||
`WebServerOptions.host` is required. The HTTP carrier passes that value to `node:http` without supplying a fallback, leaving each shell responsible for its bind policy. Programmatic carrier consumers may select another hostname or address directly.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `0.0.0.0` as the default.** Rejected because ordinary same-machine use does not need network-wide reachability and should not acquire it implicitly.
|
||||
|
||||
**Use a boolean exposure flag.** Rejected because `--host 0.0.0.0` names the resulting socket behavior directly and matches the underlying server option without introducing a second term.
|
||||
|
||||
**Default inside `startWebServer()`.** Rejected because the carrier has multiple possible shells and no basis for choosing their deployment policy. Requiring `host` makes the choice visible at every assembly call.
|
||||
|
||||
## Consequences
|
||||
|
||||
Local `dsh web` starts remain reachable at `http://127.0.0.1:3080`; a browser on another machine must opt in with `dsh web --host 0.0.0.0`. The CLI does not yet expose custom interface addresses or IPv6 modes, while programmatic carrier consumers retain that flexibility. Server tests pin both loopback and all-interface forwarding into the Node listen boundary, and the web smoke continues to exercise the default CLI path.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note:显式指定 Web 绑定地址
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-web-bind-address.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
即便浏览器与服务器运行在同一台机器上,`dsh web` 也会绑定所有网络接口。因此,本地使用会在操作者未明确选择的情况下暴露一个未经身份验证的开发服务器;另一方面,远程容器和局域网浏览器场景仍需要一种受支持的方式来接受非环回连接。
|
||||
|
||||
HTTP 承载层还把绑定地址隐藏在 `startWebServer()` 内部,导致其他壳层无法在包(package)边界明确表达自己的网络策略。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh web` 默认绑定 `127.0.0.1`。CLI(命令行界面)接受 `--host 0.0.0.0` 作为显式启用的全接口模式,并拒绝其他取值,使网络模式保持为一份规模小、经过审慎限定的契约。全接口模式仍然输出本机环回 URL,并在可用时输出第一个外部 IPv4 URL。
|
||||
|
||||
`WebServerOptions.host` 为必填项。HTTP 承载层将该值直接传给 `node:http`,不提供回退值,因此每个壳层负责制定自己的绑定策略。以编程方式使用承载层的消费方可以直接选择其他主机名或地址。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保留以 `0.0.0.0` 作为默认值。** 不予采纳,因为普通的同机使用不需要在全网范围内可达,也不应隐式获得这种可达性。
|
||||
|
||||
**使用布尔型暴露标志。** 不予采纳,因为 `--host 0.0.0.0` 直接说明最终的套接字行为,并与底层服务器选项一致,无需再引入第二套术语。
|
||||
|
||||
**在 `startWebServer()` 内设置默认值。** 不予采纳,因为承载层可能由多种壳层调用,没有依据替它们选择部署策略。要求传入 `host`,可使每次装配调用都明确作出这一选择。
|
||||
|
||||
## 后果
|
||||
|
||||
`dsh web` 的本地启动仍可通过 `http://127.0.0.1:3080` 访问;其他机器上的浏览器必须使用 `dsh web --host 0.0.0.0` 显式启用。CLI 尚未开放自定义接口地址或 IPv6 模式,而以编程方式使用承载层的消费方仍保留这种灵活性。服务器测试将环回模式和全接口模式向 Node 监听边界的传递固定为契约,Web 冒烟测试继续覆盖默认 CLI 路径。
|
||||
@@ -4,36 +4,45 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered command chain makes the workflow wall clock equal the sum of those gates, while splitting every leaf gate into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck.
|
||||
The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered command chain makes the workflow wall clock equal the sum of those gates, while splitting every short leaf into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck.
|
||||
|
||||
The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and built-bin smoke tests need the built `lib/` outputs, while most gates only need source and dependencies. A blind fan-out either races those artifact consumers before `pnpm run build` has emitted declarations and bundles, or repeats the build in every artifact-dependent job.
|
||||
The original broad-lane split stopped meeting that balance as the workspace grew. On the merge of PR #404, Linux static, coverage, snapshot, and artifact jobs took 148, 195, 94, and 230 seconds; Windows static and artifacts took 251 and 482 seconds. Package-manager packing once per package dominated both artifact validators, coverage needlessly rebuilt output before a source-only suite, and CPU-heavy gates contended inside the static and coverage lanes.
|
||||
|
||||
The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, compiled invariant loading, and built-bin smoke tests need emitted `lib/` output. Sharding cannot race those consumers ahead of build or replace their published-artifact signal with source execution.
|
||||
|
||||
## Decision
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) groups keyless checks into broad primary-runtime lanes plus a compatibility matrix. The workflow file owns the current lane and runtime inventory.
|
||||
The production topology below is historical and is superseded by [Evidence-based larger hosted runners](2026-07-22-evidence-based-larger-hosted-runners.md). The larger-runner decision removes its shard selectors and workflow jobs; this note preserves why that earlier topology was implemented.
|
||||
|
||||
Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), which schedules independent gates with bounded concurrency and prints an attributable result block for each one. Artifact consumers depend on one build within their lane, while compatibility jobs combine typechecking with a real unbuilt worker launch to cover runtime-specific loader behavior.
|
||||
[CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle.
|
||||
|
||||
Generated `.sessions/` logs and `.doc-typecheck-*` temp directories are ignored by lint. The aggregate local CI mode still runs demo smoke after lint, while the split GitHub static lane can run demo smoke directly because lint is isolated in its own lane.
|
||||
In that topology, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) was the common bounded scheduler and GitHub supplied explicit shard names for the expensive gate families. `scripts/static-shards.ts` partitioned static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build ownership and rejected a missing or duplicate gate assignment. Linux lint used disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes, while Windows used complete package-source and package-test lanes; both included a repository complement starting from `.` so new top-level targets could not disappear between shards and owned the single cross-file duplication run. `scripts/coverage-shards.ts` assigned every workspace package to exactly one source-coverage lane. Directory filters retained a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named siblings. Each coverage lane included only its owned source files, repeated the exhaustive companion topology test, and ran without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed.
|
||||
|
||||
Build output is produced once inside the Node 24 artifact lane. The artifact consumers (`publint`, `verify-node-next-types`, and built-bin smoke) declare a dependency on `build`, so there is no upload/download handoff and no consumer can race ahead of declarations or bundles. The CI coverage reporter is text-only while local coverage keeps the HTML report.
|
||||
Snapshot replay used two explicit multi-file lanes and eight scenario partitions of the large ACP file. `scripts/snapshot-shards.ts` owned that inventory, and its test discovered every file admitted by the snapshot config. Each snapshot job installed dependencies while its Linux runner prepared Bubblewrap, built the shipped runtime, and ran only its assigned replay surface. The suite retained bounded concurrency of five subprocesses because replay spent most of its time waiting on child protocol I/O. Fixture guards still inspected the complete ACP scenario table in every partition.
|
||||
|
||||
Both workflows cache the pnpm store. The real-API workflow uses the shared bounded Vitest file pool rather than a separate job per test group.
|
||||
Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; separate blocking Windows build and production-site lanes preserve the emitted-package and shipped-site checks without putting both critical paths in one job.
|
||||
|
||||
Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus one built-bin smoke lane. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded.
|
||||
|
||||
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) calls publint's supported API in-process against an in-memory publication view made from each manifest's declared files and npm's mandatory metadata files. This preserves the distinction between workspace files and published files without spawning a package-manager pack command 103 times. [scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) stages those structurally validated manifest-declared `lib/` files below the real package, then imports the compiled self-reference through plain Node and Cordis Loader normalization. A companion that reaches an undeclared runtime chunk still fails.
|
||||
|
||||
Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal.
|
||||
|
||||
The workflow caches the pnpm store, keys each immutable ESLint cache to its owning lint shard, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build and production-site validation remain blocking, while the wider Windows static, lint, and artifact matrix remains observational.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the full serial chain in a Node matrix** - simplest to reason about, but it duplicates repo-wide gates that do not produce Node-version-specific signal and leaves every PR waiting for the sum of all gates.
|
||||
- **Run every gate as a separate GitHub job** - maximizes GitHub-visible fan-out, but it creates too many checks and pays repeated setup/install overhead for gates whose runtime is shorter than the runner preparation.
|
||||
- **Upload build artifacts to artifact-dependent jobs** - preserves correctness across many jobs, but it adds artifact upload/download time and keeps the workflow wide when the artifact consumers can run behind a local dependency in the primary job.
|
||||
- **Run `typecheck` and `build` concurrently** - exposes more work to the scheduler, but both commands invoke `tsc -b`; sharing incremental build state between them is a needless race for a small wall-clock gain.
|
||||
- **Use unbounded real-API e2e parallelism** - rejected because the suite includes many live model/tool scenarios; the worker pool needs an explicit `DSH_E2E_MAX_WORKERS` cap so CI and local runs can fan out without hiding quota or resource problems behind flaky rate-limit failures.
|
||||
- **Keep the broad lanes** - minimizes workflow YAML, but it preserves the measured multi-minute feedback loop.
|
||||
- **Run every leaf gate as a separate GitHub job** - maximizes fan-out, but short generators and prose checks would spend more time preparing a runner than checking the repository.
|
||||
- **Upload one build to artifact consumers** - avoids repeated compilation, but upload/download and dependency scheduling lengthen wall time; the clean build is short enough to repeat inside bounded lanes.
|
||||
- **Keep package-manager packing in both publication gates** - delegates inventory selection to pnpm, but repeats more than 200 package-manager processes. The manifest structural gate plus publication-view fixtures make the optimized inventory contract explicit and fail on an on-disk but unpublished dependency.
|
||||
- **Keep build before coverage** - provides emitted output the source suite no longer consumes; a clean-tree coverage proof showed it was pure latency.
|
||||
- **Typecheck on every Node version** - repeats compiler work while the compatibility smokes already exercise actual Node-specific loading and compression behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
PR feedback arrives as a few GitHub checks with structured per-gate log blocks inside each broad job. That keeps runner setup overhead bounded and the Actions UI compact, at the cost of losing one status check per leaf gate.
|
||||
The shard inventories and matrix jobs described above are not part of the current repository contract. The superseding larger-runner decision keeps the complete primary inventory in one process and uses the serial suite as its independent completeness oracle.
|
||||
|
||||
The broad-lane split repeats checkout, setup, and install more often than a single primary job. That setup cost is intentional: on GitHub's hosted runner, running lint, coverage, and snapshot replay in one process pool oversubscribes CPU badly enough that the single-job critical path is longer than the repeated setup.
|
||||
The optimized publication validators rely on the manifest `files` contract enforced by `verify-package-invariants`. If publication rules grow beyond that contract, the structural gate and both staged views must change together.
|
||||
|
||||
The split introduces a maintenance obligation: when `package.json` adds or removes a gate that belongs in CI, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) needs the matching leaf. That obligation is intentional because the runner is the parallel execution plan for the same gate vocabulary, not a separate quality policy.
|
||||
|
||||
The compatibility signal is narrower than the primary Node 24 signal. It proves that the source graph typechecks and that the real unbuilt workflow-worker launch path executes on every advertised runtime line without doubling documentation, coverage, publication, snapshot replay, and unrelated smoke checks whose failures are not expected to vary by Node version.
|
||||
Compatibility jobs no longer claim that TypeScript itself was exercised under every Node runtime. They prove runtime-sensitive source loading on Node 22, 24, and 26, while the primary runtime owns the single source-graph typecheck.
|
||||
|
||||
@@ -10,7 +10,7 @@ Aggregate jobs such as documentation synchronization hide long sequential chains
|
||||
|
||||
## Decision
|
||||
|
||||
[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI and `doc-sync`. It expands named modes into leaf gates, respects artifact dependencies, buffers attributable output, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound.
|
||||
[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, respects artifact dependencies, buffers attributable output, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound.
|
||||
|
||||
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages/<group>/<pkg>` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block.
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ Locale home projections retain only the canonical YAML frontmatter. The reposito
|
||||
|
||||
The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image becomes a raw GitHub URL. Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates.
|
||||
|
||||
`website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest.
|
||||
|
||||
Mermaid renders the canonical diagrams. The website workspace explicitly declares the five packages that `vitepress-plugin-mermaid` asks Vite to prebundle because pnpm's strict dependency isolation otherwise makes those transitive packages unavailable to the local development server; Knip records this runtime-only use as an intentional dependency exception.
|
||||
|
||||
Site publication remains separate from site construction. A dedicated GitHub Actions workflow runs the existing documentation gates, uploads `website/.dist` as a Pages artifact, and deploys only after the build succeeds. `actions/configure-pages` supplies the destination's base path to VitePress at build time, so the private Pages origin, a later public project path, and a custom domain do not require distinct checked-in configurations. Pages visibility remains a repository hosting setting rather than a workflow permission.
|
||||
@@ -38,6 +40,6 @@ Site publication remains separate from site construction. A dedicated GitHub Act
|
||||
|
||||
## Consequences
|
||||
|
||||
Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. Merges that affect the documentation site deploy the checked result to Pages, while manual dispatch provides a recovery and validation entry point.
|
||||
Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. The layout gate makes an obsolete site-specific Markdown tree a merge failure instead of ignored build input. Merges that affect the documentation site deploy the checked result to Pages, while manual dispatch provides a recovery and validation entry point.
|
||||
|
||||
The publication manifest is a maintained allowlist, and link projection adds a small repository-specific build adapter. A new kind of Markdown link behavior needs a projector test. Mermaid support also increases the client bundle size, but preserves diagrams already used by the canonical documentation.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-serial-cross-platform-ci-reference.md: ffc1fd5b37bc6c9e3427ee55a55300f93a1292f3
|
||||
2026-07-21-serial-cross-platform-ci-reference.zh.md: d7f87916865b83973abe6b0708203618cf536c8e
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Serial cross-platform CI reference
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-serial-cross-platform-ci-reference.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The pull-request workflow reaches its latency targets by scheduling the complete primary Node inventory concurrently inside one larger runner. The optimized scheduler still should not be its own only completeness oracle: a defect in its gate inventory or dependency graph could omit work while the optimized job stays green.
|
||||
|
||||
Encoding the one-minute non-Windows target and three-minute Windows target as job timeouts creates a separate failure mode. Hosted-runner startup and performance vary, so a correct gate can be cancelled at the target boundary before it emits useful diagnostics. The performance objective needs measurement against GitHub timestamps, while correctness needs enough time to finish.
|
||||
|
||||
Reviewers also need a direct answer to a simpler question: what happens when the repository's complete primary Node CI aggregate runs without matrix selection, shard variables, or concurrent gates on each selected hosted operating system?
|
||||
|
||||
## Decision
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run only the optimized larger-runner and compatibility jobs. A push to `master` skips those jobs and runs three explicit references named `serial / linux`, `serial / macos`, and `serial / windows`. They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
|
||||
|
||||
Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
|
||||
|
||||
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only the optimized jobs; a master push runs only the three serial references. The one-minute non-Windows and three-minute Windows objectives are evaluated from completed hosted-job timestamps and reported as measurements; they are not `timeout-minutes` values.
|
||||
|
||||
The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. A higher-core hosted runner remains a possible future benchmark, but it is not the default: larger runners require organization-owned labels and provisioning, while a reference oracle should remain runnable without repository-external runner configuration. Provisioning one later can change the performance experiment without changing this correctness baseline.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression.
|
||||
- **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
|
||||
- **Run the serial references on every pull request** - rejected because they deliberately trade wall time and runner consumption for simplicity and are not needed in the fast feedback loop.
|
||||
- **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
|
||||
- **Run the serial reference on larger runners** - rejected because the reference is the portable fallback for the organization-specific pull-request topology. The fast pull-request path uses provisioned larger runners; the serial master path keeps standard labels.
|
||||
|
||||
## Consequences
|
||||
|
||||
The workflow contains duplicated setup steps and a master reference run can take much longer than the optimized pull-request path. That duplication is deliberate: reviewers can inspect each operating system's complete command without resolving a matrix or concurrent scheduler.
|
||||
|
||||
The reference may expose platform failures that the optimized blocking set does not yet claim to support, especially on Windows. Such a failure is evidence about current cross-platform behavior rather than a reason to weaken or silently skip the aggregate.
|
||||
|
||||
Removing strict duration timeouts means a latency regression is observed rather than automatically cancelled. Hosted measurements must therefore accompany performance changes, while the completed logs retain the information needed to optimize the slow lane.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 跨平台串行 CI 参考流程
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-serial-cross-platform-ci-reference.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
拉取请求工作流通过在一台更大型运行器内并发调度完整的主 Node 门禁清单来达到延迟目标。优化调度器仍不应成为自身唯一的完整性判定基准:如果其门禁清单或依赖图存在缺陷,即使优化作业保持绿灯,也可能漏掉部分工作。
|
||||
|
||||
将非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标写成作业超时,会引入另一种失败模式。托管运行器的启动时间和性能会波动,因此即使门禁本身正确,也可能在到达目标时间边界时被取消,来不及输出有用的诊断信息。性能目标需要根据 GitHub 时间戳衡量,而正确性验证需要给门禁留足完成时间。
|
||||
|
||||
评审人还需要直接回答一个更简单的问题:在每个选定的托管操作系统上,如果仓库完整的主 Node CI 聚合流程不使用矩阵选择、分片变量或并发门禁,运行结果会怎样?
|
||||
|
||||
## 决策
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求只运行使用更大型运行器的优化作业和兼容性作业。向 `master` 推送时会跳过这些作业,改为运行三个显式参考作业,名称分别为 `serial / linux`、`serial / macos` 和 `serial / windows`。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
|
||||
|
||||
每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
|
||||
|
||||
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行优化作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估非 Windows 作业的 1 分钟目标和 Windows 作业的 3 分钟目标,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
|
||||
|
||||
可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。仍可将更高核心数的托管运行器作为未来的基准测试,但不将其设为默认选择:更大型运行器需要组织自有的标签和预配,而参考判定基准应无需仓库外部的运行器配置即可运行。日后完成这类预配,可以改变性能实验而无需改变该正确性基线。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。
|
||||
- **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
|
||||
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业有意以更长的总耗时和更多运行器用量换取简单性,快速反馈循环不需要它们。
|
||||
- **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
|
||||
- **在更大型运行器上运行串行参考流程**:不予采纳,因为该参考流程是特定组织拉取请求拓扑的可移植后备方案。快速拉取请求路径使用已预配的更大型运行器;串行 master 路径保留标准标签。
|
||||
|
||||
## 后果
|
||||
|
||||
工作流包含重复的设置步骤,master 参考运行也可能比优化后的拉取请求路径耗时长得多。这些重复是有意保留的:评审人无需解析矩阵或并发调度器,就能直接检查每种操作系统执行的完整命令。
|
||||
|
||||
参考流程可能暴露某些平台上的故障,而优化后的阻塞门禁集合尚未声明支持这些平台,Windows 尤其如此。这类失败反映了当前的跨平台行为,不应成为削弱或静默跳过该聚合流程的理由。
|
||||
|
||||
移除严格的时长超时后,系统会观测到延迟回归,而不是在发生回归时自动取消运行。因此,性能改动必须附带托管环境测量结果,已完成的日志则保留优化最慢通道所需的信息。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-evidence-based-larger-hosted-runners.md: c0fae2841f21c431d6416cd5d421929d70197abb
|
||||
2026-07-22-evidence-based-larger-hosted-runners.zh.md: 51c73a8a631af4f1254c795d09585770fc4e68bb
|
||||
@@ -0,0 +1,75 @@
|
||||
# Agent Note: Evidence-based larger hosted runners
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-evidence-based-larger-hosted-runners.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The shard-heavy CI topology met its latency targets by spreading primary Node work across 40 Linux jobs and Windows work across nine jobs. Most gates were shorter than checkout, runner setup, cache restore, and dependency installation, so repeated setup waves created both cost and latency variance. One hosted run finished its slowest Linux job in 49 seconds yet took 231 seconds for a Windows lint shard whose checkout, cache restore, and install alone consumed 158 seconds.
|
||||
|
||||
Larger runners make it possible to pay setup once and parallelize inside the repository scheduler, but the useful size cannot be selected from core counts alone. Critical-lane benchmarks did not scale monotonically, and a whole-repository aggregate exposed different bottlenecks from isolated typecheck or site builds.
|
||||
|
||||
## Decision
|
||||
|
||||
The organization keeps twelve x64 larger-runner pools in the repo-restricted `dsh-larger-ci` group: Ubuntu 24.04 and Windows 2025 at 4, 8, 16, 32, 64, and 96 cores. Public IPs are disabled. Each pool has an autoscaling ceiling of 256; the ceiling does not allocate idle machines or remove the need to bound workflow demand.
|
||||
|
||||
Production CI uses five larger-runner executions and one standard-runner aggregator. The primary Node inventory is not sharded:
|
||||
|
||||
- `node 24 / complete` uses one 96-core Linux runner. One checkout, direct selection of the image's preinstalled Node 24 toolcache, pnpm- and ESLint-cache restore, and install feeds all 42 primary gates. `run-gates` starts up to 10 independent gates; ESLint and coverage use at most 16 workers, and snapshot replay uses at most 8. Build starts as soon as the first short gates release scheduler slots, while snapshot replay and publication consumers retain explicit dependencies on emitted `lib/` output. Pull requests restore both caches without saving them, so cache compression and upload do not extend the required job; the master serial reference refreshes those caches outside the pull-request critical path. An uncached exact-head trace put ESLint at 38.11 seconds and coverage at 37.10 seconds, so the small ESLint restore remains useful on the critical path. The read-only job does not persist checkout credentials.
|
||||
- Node 22.19 and Node 26 use the 4- and 32-core Linux pools for their runtime compatibility smokes. Python 3.10 uses the 8-core Linux pool for the complete keyless SDK suite. These are environment contracts, not slices of the primary Node gate inventory.
|
||||
- `windows node 24 / complete` uses one 32-core Windows runner. One preparation wave feeds the required package build, required production site build, and complete observational portability inventory. Required failures fail the job; observational failures are reported as non-blocking. ESLint stays single-threaded because 16 ESLint workers took 174.54 seconds, coverage uses at most 12 workers, and the outer scheduler retains 16 slots. The job restores only the small master-refreshed ESLint cache and performs a clean pnpm install instead of restoring or saving the many-file package store. All six Windows larger-runner sizes completed install and the production-site benchmark without mutating the machine-wide Developer Mode registry key, so the pull-request critical path omits that redundant step.
|
||||
|
||||
The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture.
|
||||
|
||||
An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction:
|
||||
|
||||
| Complete Linux primary | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Active time | 243 s | 144 s | 103 s | 87 s | 62 s | 65 s |
|
||||
|
||||
The 96-core trace spent 39.14 seconds in repository gates. Typecheck occupied 25.71 seconds, then a scheduler dependency delayed the 2.13-second build and 11.29-second snapshot replay until it finished. The same run already proved build and typecheck independently, and the former CPU lane ran them concurrently. Removing that dependency makes lint at 33.30 seconds the measured critical gate while preserving dependencies only for consumers of build output. The 64-core trace exposed the same idle chain: typecheck, build, and snapshot consumed 44.85 seconds in sequence while its independent lint and documentation builds finished in 36.83 and 36.15 seconds. More cores therefore become useful only after the repository scheduler can feed them.
|
||||
|
||||
The same benchmark measured the required Windows build surfaces across every provisioned size:
|
||||
|
||||
| Windows blocking builds | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Active time | 152 s | 104 s | 104 s | 92 s | 103 s | 110 s |
|
||||
|
||||
Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated.
|
||||
|
||||
The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head production run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. Production therefore avoids the Windows package-store cache, uses restore-only caches on latency-critical pull-request jobs, and bounds outer concurrency so typecheck, lint, coverage, and build do not oversubscribe one host.
|
||||
|
||||
Three host effects remain part of the decision. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, which is why environment contracts use distinct larger-runner pools instead of standard capacity. The setup-node action later spent 3.68 seconds printing cached Linux environment details and 46.56 seconds doing the same on Windows after both had already found Node 24.18.0 in the hosted toolcache. The two latency-critical jobs select the newest preinstalled 24.x directory directly, verify its major, and fail loud if the image no longer carries it; compatibility jobs retain setup-node because selecting a non-default runtime is their contract. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation.
|
||||
|
||||
Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Production therefore retains 16 ESLint workers and admits 10 independent repository gates at once, leaving capacity for the worker pools owned by those gates without starving later independent work.
|
||||
|
||||
Linux coverage caps each project at 16 workers, while Windows keeps the 12-worker cap. The process-bound project contains exactly five suite files, so its fork count cannot reach either cap. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite: under aggregate gate contention its thread worker completed every test but intermittently missed the stdin-error callback needed for per-file function coverage. It also includes the pi-ai adapter suite after two hosted aggregate runs delayed an idle-watchdog socket-close observation past its 100-millisecond test deadline. A 32-worker all-gate run on the 96-core host slowed coverage to 44.6 seconds and made a compute-budget regression cross its one-second threshold, so production stops at 16. This preserves the suites' isolation contracts and deterministic coverage while avoiding forked execution for ordinary test files.
|
||||
|
||||
The workflow retains two manual measurement suites. `suite=larger-runner-benchmark` compares isolated critical lanes across every size, and `suite=consolidated-runner-benchmark` compares whole aggregates. Complete serial Linux, macOS, and Windows references run only when `master` moves; pull requests run only the optimized jobs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the three coarse primary Linux lanes.** The core, CPU, and production-site jobs met the latency targets, but they paid three setup waves and left primary Node work sharded after larger runners were available. The all-size trace showed that one unnecessary dependency, not a lack of host capacity, kept the single-box aggregate above one minute.
|
||||
|
||||
**Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises.
|
||||
|
||||
**Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. Production uses 96 cores for the shorter controllable critical path; the benchmark suite retains both pools so a sustained image or pricing change can reverse that choice with evidence.
|
||||
|
||||
**Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it.
|
||||
|
||||
**Keep compatibility and Python on standard runners.** Warm standard runs can fit, but runner setup alone has crossed the non-Windows target. Distinct larger pools isolate these environment contracts from that allocation lottery.
|
||||
|
||||
**Keep required and observational Windows checks in separate jobs.** The split preserves status semantics at the workflow level but pays setup twice. `run-gates` preserves the same required versus non-blocking distinction inside one process.
|
||||
|
||||
**Install Bubblewrap through the system package manager.** This uses the host's package database and can dominate the job even when the payload is tiny. Pinned extraction plus a confinement probe preserves the runtime contract without mutating the hosted image.
|
||||
|
||||
## Consequences
|
||||
|
||||
Primary Node CI has one job, one setup wave, one complete gate inventory, and no shard selectors. Together with two Node compatibility executions, Python, and Windows, production has five paid larger-runner executions instead of seven coarse-lane executions or 49 gate-level executions.
|
||||
|
||||
GitHub rounds each larger-runner execution up to a whole minute, so eliminating setup waves reduces billed time as well as workflow complexity. The final aggregator remains on a standard runner because it begins only after the paid jobs release capacity.
|
||||
|
||||
The current targets are observed performance contracts, not cancellation deadlines. Exact-head production runs must show every non-Windows job below one minute and the consolidated Windows job below three minutes; manual all-size and serial suites remain available when image, dependency, scheduler, or pricing changes need remeasurement.
|
||||
|
||||
Production CI depends on the organization-owned runner labels in [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml). Missing or renamed pools leave jobs queued instead of falling back to standard capacity. All twelve pools remain provisioned so the manual benchmarks can re-evaluate the production size without an administrative setup cycle.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Agent Note: 基于实证选用 GitHub 托管大型运行器
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-evidence-based-larger-hosted-runners.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
高度分片的 CI 拓扑通过把主 Node 工作分散到 40 个 Linux 作业、把 Windows 工作分散到 9 个作业来达到延迟目标。大多数门禁本身的耗时短于代码检出、运行器设置、缓存恢复和依赖安装这些准备阶段,因此反复执行多轮设置既增加成本,也带来延迟波动。一次托管运行中最慢的 Linux 作业用时 49 秒,而一个 Windows lint 分片却耗时 231 秒,其中仅代码检出、缓存恢复和安装就占了 158 秒。
|
||||
|
||||
大型运行器可以让 CI 只承担一次设置开销,再由仓库调度器在内部并行执行,但无法仅凭核心数选出有实际价值的规格。关键通道基准测试的性能提升不呈单调变化,完整仓库聚合流程暴露出的瓶颈也不同于单独运行类型检查或网站构建时的瓶颈。
|
||||
|
||||
## 决策
|
||||
|
||||
组织在仅限本仓库使用的 `dsh-larger-ci` 运行器组中保留 12 个 x64 大型运行器池:Ubuntu 24.04 和 Windows 2025 各设 4、8、16、32、64、96 核规格。公网 IP 已禁用。每个池的自动扩缩容上限为 256;该上限既不会分配闲置机器,也不能免除限制工作流需求的必要性。
|
||||
|
||||
生产 CI 包含 5 次大型运行器执行和 1 个标准运行器聚合作业。主 Node 门禁清单不再分片:
|
||||
|
||||
- `node 24 / complete` 使用一台 96 核 Linux 运行器。只需执行一次代码检出、直接选择托管映像中预装的 Node 24 toolcache、恢复 pnpm 和 ESLint 缓存以及安装,即可供全部 42 项主门禁使用。`run-gates` 最多同时启动 10 项相互独立的门禁;ESLint 和覆盖率最多使用 16 个工作线程,快照回放最多使用 8 个。第一批短门禁释放调度器槽位后,构建会立即启动,而快照回放和发布消费方仍显式依赖生成的 `lib/` 输出。拉取请求会恢复这两项缓存但不保存,因此缓存压缩和上传不会延长必需作业;master 上的串行参考会在拉取请求关键路径之外刷新这两项缓存。一次未使用缓存的分支头精确运行轨迹显示,ESLint 耗时 38.11 秒,覆盖率耗时 37.10 秒,因此在关键路径上恢复这个较小的 ESLint 缓存仍有价值。该只读作业不会持久化代码检出凭据。
|
||||
- Node 22.19 和 Node 26 分别使用 4 核和 32 核 Linux 池运行各自的运行时兼容性冒烟测试。Python 3.10 使用 8 核 Linux 池运行完整的无密钥 SDK 套件。这些作业属于环境契约,并非主 Node 门禁清单的分片。
|
||||
- `windows node 24 / complete` 使用一台 32 核 Windows 运行器。一轮准备工作供必需的包构建、必需的生产网站构建以及完整的观测性可移植性清单共用。任何必需项失败都会使作业失败;观测项失败则报告为非阻塞。ESLint 保持单线程,因为 16 个 ESLint 工作线程耗时 174.54 秒;覆盖率最多使用 12 个工作线程,外层调度器则保留 16 个槽位。该作业仅恢复由 master 刷新的较小 ESLint 缓存,并在干净环境中执行 pnpm 安装,而不恢复或保存包含大量文件的包存储。全部 6 种 Windows 大型运行器规格都在未修改系统级 Developer Mode 注册表项的情况下完成了安装和生产网站基准测试,因此拉取请求关键路径省略了这个多余步骤。
|
||||
|
||||
原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。
|
||||
|
||||
一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程:
|
||||
|
||||
| Linux 完整主流程 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| 活动耗时 | 243 秒 | 144 秒 | 103 秒 | 87 秒 | 62 秒 | 65 秒 |
|
||||
|
||||
96 核运行轨迹中的仓库门禁耗时 39.14 秒。类型检查占用 25.71 秒,随后一项调度器依赖使耗时 2.13 秒的构建和耗时 11.29 秒的快照回放都要等到类型检查结束后才启动。同一次运行已经分别证明构建和类型检查可以独立执行,原 CPU 通道也曾让二者并发运行。移除这项依赖后,耗时 33.30 秒的 lint 成为实测关键门禁,而只有构建输出的消费方仍保留依赖关系。64 核运行轨迹暴露了相同的空闲链:类型检查、构建和快照依次执行,共耗时 44.85 秒,而相互独立的 lint 和文档构建分别在 36.83 秒和 36.15 秒内完成。因此,只有仓库调度器能够为更多核心持续提供工作时,增加核心数才有价值。
|
||||
|
||||
同一项基准测试还测量了每种已预配规格上的 Windows 必需构建项:
|
||||
|
||||
| Windows 阻塞性构建 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| 活动耗时 | 152 秒 | 104 秒 | 104 秒 | 92 秒 | 103 秒 | 110 秒 |
|
||||
|
||||
Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。
|
||||
|
||||
客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的生产运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,生产环境不使用 Windows 包存储缓存,在对延迟敏感的拉取请求作业中使用只恢复不保存的缓存,并限制外层并发度,以免类型检查、lint、覆盖率和构建在同一台主机上过度争用资源。
|
||||
|
||||
3 项主机效应仍构成这项决策的依据。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上,因此各项环境契约使用不同的大型运行器池,而非标准容量。setup-node action 在 Linux 和 Windows 均已从托管 toolcache 找到 Node 24.18.0 后,仍分别花费 3.68 秒和 46.56 秒输出缓存的环境详情。两个延迟关键作业会直接选择最新的预装 24.x 目录并验证其主版本号;如果映像不再提供该目录,作业会明确报错并失败。兼容性作业仍使用 setup-node,因为选择非默认运行时正是它们的契约。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定包内容并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。
|
||||
|
||||
内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,生产环境将 ESLint 工作线程上限维持在 16 个,并且同时最多运行 10 项相互独立的仓库门禁,既为这些门禁自身的工作线程池留出容量,又避免后续独立工作因资源不足而迟迟无法启动。
|
||||
|
||||
Linux 覆盖率把每个项目的工作线程上限设为 16 个,Windows 则保留 12 个工作线程的上限。进程约束项目恰好包含 5 个套件文件,因此它的 fork 数量不可能达到任一上限。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单还包含本地 bash 进程通路套件:在聚合门禁争用资源时,该套件的工作线程虽然完成了所有测试,却会间歇性漏记逐文件函数覆盖率所需的 stdin 错误回调。两次托管聚合运行都将空闲看门狗对套接字关闭的观测延迟到超过其 100 毫秒测试截止时间,因此这份清单还包含 pi-ai 适配器套件。在 96 核主机上使用 32 个工作线程运行全部门禁时,覆盖率耗时变慢至 44.6 秒,还使一项计算预算回归超过其 1 秒阈值,因此生产环境将工作线程数限制在 16 个以内。这样既能保留这些套件的隔离契约和覆盖率结果的确定性,又能避免以 fork 方式执行普通测试文件。
|
||||
|
||||
工作流保留 2 项手动测量套件。`suite=larger-runner-benchmark` 比较所有规格下相互独立的关键通道,`suite=consolidated-runner-benchmark` 比较完整聚合流程。只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考;拉取请求只运行优化后的作业。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保留 3 个粗粒度 Linux 主流程通道。** 核心、CPU 和生产网站作业均达到延迟目标,但它们需要 3 轮设置,而且在大型运行器已经可用后仍对主 Node 工作进行分片。全规格运行轨迹表明,让单机聚合流程超过 1 分钟的是一项不必要的依赖,而非主机容量不足。
|
||||
|
||||
**将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。
|
||||
|
||||
**使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。生产环境使用 96 核来缩短可控的关键路径;基准测试套件保留两种规格,因此如果映像或定价发生持续性变化,仍可根据证据反转这项选择。
|
||||
|
||||
**让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。
|
||||
|
||||
**让兼容性和 Python 继续使用标准运行器。** 标准运行器热运行可以达到目标,但仅运行器设置一项就曾超过非 Windows 目标。不同的大型运行器池可以让这些环境契约免受这种分配波动影响。
|
||||
|
||||
**将必需的 Windows 检查和观测性 Windows 检查保留在不同作业中。** 这种拆分在工作流层保留状态语义,却需要支付两次设置开销。`run-gates` 在一个进程内保留了相同的必需与非阻塞区别。
|
||||
|
||||
**通过系统包管理器安装 Bubblewrap。** 此方案会使用主机的包数据库,即使包内容很小,也可能主导整个作业耗时。固定版本的解压方式配合隔离探针,无需修改托管映像即可保留运行时契约。
|
||||
|
||||
## 后果
|
||||
|
||||
主 Node CI 只有 1 个作业、1 轮设置、1 份完整门禁清单,而且没有分片选择器。加上 2 次 Node 兼容性执行、Python 和 Windows,生产环境共有 5 次付费大型运行器执行,而非 7 次粗粒度通道执行或 49 次门禁级执行。
|
||||
|
||||
GitHub 会把每次大型运行器执行向上取整到整分钟计费,因此消除设置轮次既能减少计费时长,也能降低工作流复杂度。最终聚合作业仍使用标准运行器,因为它只会在付费作业释放容量后启动。
|
||||
|
||||
当前目标是基于观测得到的性能契约,而非取消截止时间。分支头精确的生产运行必须表明每个非 Windows 作业都低于 1 分钟,合并后的 Windows 作业低于 3 分钟;当映像、依赖、调度器或定价发生变化而需要重新测量时,仍可使用手动全规格和串行套件。
|
||||
|
||||
生产 CI 依赖 [`.github/workflows/ci.yml`](../../../../.github/workflows/ci.yml) 中由组织持有的运行器标签。池缺失或改名会让作业一直排队,不会回退到标准容量。全部 12 个池均保持已预配状态,因此手动基准测试无需再次经过管理配置周期,就能重新评估生产规格。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-fast-local-git-hooks.md: bab47c6479f1a2c01cbfa7152b1d610917fb6175
|
||||
2026-07-22-fast-local-git-hooks.zh.md: 7b279b1a9ad86e09ed5cf7d2470cb61ff17e09b7
|
||||
2026-07-22-fast-local-git-hooks.md: a07af1cd424c86f7fa80ea946cd5012362cc66eb
|
||||
2026-07-22-fast-local-git-hooks.zh.md: 78d4ea8980476609a9140737a75152eba123b308
|
||||
|
||||
@@ -14,7 +14,7 @@ Fast hooks still need to reject cheap, high-confidence defects before work leave
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: ESLint fixes and re-stages changed JavaScript and TypeScript, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode.
|
||||
|
||||
Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The `check:pre-push` package script and `pre-push` scheduler mode do not exist; [scripts/run-gates.ts](../../../../scripts/run-gates.ts) continues to own CI and `doc-sync` scheduling.
|
||||
Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The opt-in `check:all` package script selects the `check-all` scheduler inventory in [scripts/run-gates.ts](../../../../scripts/run-gates.ts) independently of the hooks; it is a contributor command, not an agent instruction.
|
||||
|
||||
Agents inspect the outgoing diff and run the narrowest tests and checks that cover its behavior once. CI owns exhaustive coverage, built-artifact checks, and the platform matrix. A complete local rehearsal is reserved for an explicit request, CI diagnosis, or a repository-wide change that cannot be validated credibly by narrower evidence.
|
||||
|
||||
@@ -31,6 +31,6 @@ This decision supersedes the local-hook portion of [Parallel pre-push gates](202
|
||||
|
||||
## Consequences
|
||||
|
||||
Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state.
|
||||
Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state.
|
||||
|
||||
Local publication no longer proves the exhaustive repository matrix. Agents must select relevant behavioral evidence, reviewers must evaluate whether that selection matches the diff, and CI supplies the comprehensive signal once per pushed revision.
|
||||
|
||||
@@ -14,7 +14,7 @@ agent(智能体)已经会运行能够覆盖自身改动的测试和检查,
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:ESLint 修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。
|
||||
|
||||
两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。`check:pre-push` 包脚本与调度器的 `pre-push` 模式不存在;[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 继续负责 CI 和 `doc-sync` 调度。
|
||||
两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。可选运行的 `check:all` 包脚本独立于这些钩子,从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `check-all` 调度器清单;它是贡献者命令,而非对 agent 的指令。
|
||||
|
||||
agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小范围测试和检查。CI 负责全量覆盖率门禁、构建产物检查与平台矩阵。只有在明确要求、诊断 CI,或涉及全仓库的改动无法由范围更窄的证据得到可信验证时,才完整运行一遍本地检查矩阵。
|
||||
|
||||
@@ -31,6 +31,6 @@ agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小
|
||||
|
||||
## 结果
|
||||
|
||||
普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。
|
||||
普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。
|
||||
|
||||
从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符,CI 则对每个推送版本提供一次全面信号。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-installer-in-repo-skip-clone.md: f63c438205f7bd6aeb8dd78941bbe0880a8e31a1
|
||||
2026-07-22-installer-in-repo-skip-clone.zh.md: f9fe4865ad1090211c094fc8fba843b623512cc9
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: installer skips the clone when run from inside a checkout
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-installer-in-repo-skip-clone.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`scripts/install.sh` is written for the `curl ... | sh` path: it clones the harness into `~/.dsh/source`, then installs, links, and launches. Contributors who already have a checkout and run the same script directly (`sh scripts/install.sh`) got a second, unrelated clone at `~/.dsh/source` — installing and linking a different tree than the one they were working in, with no way to exercise the local script against the local source.
|
||||
|
||||
## Decision
|
||||
|
||||
The script detects when it is executing from inside a real checkout and, in that mode, reuses that checkout and skips the clone/update step entirely, leaving the working tree untouched.
|
||||
|
||||
Detection keys on `$0`: under `curl ... | sh` the script text arrives on stdin, so `$0` is the shell name and no file path resolves; running a checked-out copy makes `$0` the script file. When `$0` is a readable file whose parent is a `scripts/` directory inside a tree that carries both the `bin/dsh` launcher and `scripts/install.sh`, the script sets `IN_REPO=1` and repoints `DSH_SOURCE` at that repo root. Step 2 then prints a "using existing checkout" line and does nothing else — no `git fetch`, no `git checkout -B`, so the user's working tree and branch are never mutated. `DSH_REF` is advisory and ignored in this mode.
|
||||
|
||||
Explicit `DSH_SOURCE` wins over detection: the value is captured before defaulting, and in-repo detection only repoints an unset `DSH_SOURCE` (or one already equal to the detected repo root). Setting `DSH_SOURCE` to a different directory opts back into the normal clone/update path, so the escape hatch to install a separate tree from within a checkout still exists.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Detect via `git rev-parse --show-toplevel` on the current directory.** Rejected: `curl ... | sh` frequently runs from inside some unrelated git repo (the user's `cwd`), which would false-positive and skip the clone against a tree that is not dsh. Anchoring on `$0`'s own location ties the decision to where the script physically lives, and the `bin/dsh` + `scripts/install.sh` markers confirm it is actually a dsh checkout.
|
||||
|
||||
**Always skip the clone whenever run from a file, ignoring `DSH_SOURCE`.** Rejected: a contributor may legitimately run the in-repo script to provision a separate `~/.dsh/source` install; honoring an explicit `DSH_SOURCE` that differs from the checkout preserves that path.
|
||||
|
||||
## Consequences
|
||||
|
||||
Running `sh scripts/install.sh` from a checkout now installs, links, and launches that checkout instead of cloning a parallel one, which also makes the local script testable against local source. The cost is a detection block that couples to the repo layout (`scripts/` beside `bin/dsh`); if the launcher or script ever moves, the markers must move with it. The behavior is documented in the script header and both README files, and verified by running the four paths (in-repo skip, curl-style clone, explicit `DSH_SOURCE` elsewhere opting back in, explicit `DSH_SOURCE` equal to repo root still skipping).
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: 在检出目录内运行时安装脚本跳过克隆
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-installer-in-repo-skip-clone.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`scripts/install.sh`是为`curl ... | sh`路径编写的:它把 harness 克隆到`~/.dsh/source`,然后安装、软链接并启动。已经有检出的贡献者若直接运行同一脚本(`sh scripts/install.sh`),会在`~/.dsh/source`得到第二份无关的克隆——安装并软链接的是与他们正在工作的树不同的另一棵树,且无从用本地脚本验证本地源码。
|
||||
|
||||
## 决策
|
||||
|
||||
脚本会检测自身是否在真实检出内执行;在该模式下,它复用该检出并完全跳过克隆/更新步骤,保持工作树不受影响。
|
||||
|
||||
检测依据是`$0`:在`curl ... | sh`下脚本文本经由 stdin 到达,因此`$0`是 shell 名称、无路径可解析;运行已检出的副本会使`$0`成为脚本文件本身。当`$0`是一个可读文件、其父目录是一个`scripts/`目录、且该树同时带有`bin/dsh`启动器和`scripts/install.sh`时,脚本会设置`IN_REPO=1`并把`DSH_SOURCE`重新指向该仓库根。步骤 2 随后打印一行"using existing checkout"并不做其他事——不执行`git fetch`、不执行`git checkout -B`,因此用户的工作树和分支绝不会被改动。`DSH_REF`在该模式下仅供参考、被忽略。
|
||||
|
||||
显式的`DSH_SOURCE`优先于检测:该值在默认化之前就被捕获,检测只会重新指向未设置的`DSH_SOURCE`(或已经等于检测到的仓库根的那个)。把`DSH_SOURCE`设为其他目录会重新回到正常的克隆/更新路径,因此在检出目录内安装另一棵独立树的退路依然存在。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**通过对当前目录执行`git rev-parse --show-toplevel`来检测。** 已否决:`curl ... | sh`常常在某个无关的 git 仓库(用户的`cwd`)内运行,这会误判并对一棵并非 dsh 的树跳过克隆。把决策锚定在`$0`自身的位置,使其绑定到脚本实际所在之处,而`bin/dsh` + `scripts/install.sh`标记则确认它确实是一个 dsh 检出。
|
||||
|
||||
**只要从文件运行就总是跳过克隆,忽略`DSH_SOURCE`。** 已否决:贡献者可能合理地运行检出内脚本来配置一份独立的`~/.dsh/source`安装;尊重与检出不同的显式`DSH_SOURCE`保留了该路径。
|
||||
|
||||
## 影响
|
||||
|
||||
现在从检出目录运行`sh scripts/install.sh`会安装、软链接并启动该检出,而不是克隆一份平行副本,这也让本地脚本可以针对本地源码进行测试。代价是一段与仓库布局耦合的检测逻辑(`scripts/`与`bin/dsh`并列);若启动器或脚本将来移动,标记必须随之移动。该行为记录在脚本头部和两份 README 中,并通过运行四条路径来验证(检出内跳过、curl 式克隆、显式`DSH_SOURCE`指向他处而回到克隆、显式`DSH_SOURCE`等于仓库根仍跳过)。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-retire-readline-front-door.md: 7ebcfdc246bdf6971418609c61acbd4019aa90cb
|
||||
2026-07-20-retire-readline-front-door.zh.md: cf4d03594ed3a0cf31bed96eb2133bd37959084a
|
||||
@@ -0,0 +1,46 @@
|
||||
# Agent Note: Retire the readline front door and the repl-agent example
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-retire-readline-front-door.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The repo shipped two interactive terminal front doors: the line-oriented readline channel (`@deepseek-ai/dsh-stdio`) and the full-screen [`@deepseek-ai/dsh-tui`](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md). After the TUI landed, readline's interactive role was redundant — `demo:tui` superseded `demo:repl` as the coding-agent experience — while its remaining real role, pipes and automation, was already served better by the one-shot `@deepseek-ai/dsh-cli-demo` app (task in, DSH-native `text`/`json`/`stream-json` out, durable persistence, signal handling).
|
||||
|
||||
The duplication was structural, not just cosmetic: `dsh-stdio-demo` carried a `TerminalMode` (`auto`/`readline`/`tui`) selection seam, ~1,000 lines of readline unit tests, a readline transcript grammar (`[tool call] …` lines) that the CI demo smoke and two built-bin e2es grepped, and an inverted example composition where the flagship `tui-agent` leaf was defined as an include-patch over the `repl-agent` leaf it superseded.
|
||||
|
||||
## Decision
|
||||
|
||||
Delete the readline front door and the repl-agent example; keep exactly three front-door archetypes: **interactive TUI** (TTY-only, fails loud on pipes), **one-shot CLI** (`-p`/positional task, pipes and automation), and **servers** (ACP / JSON-RPC).
|
||||
|
||||
- `packages/ui/stdio` and `examples/repl-agent` are gone. `packages/examples/stdio-demo` is renamed `@deepseek-ai/dsh-tui-demo` (`packages/examples/tui-demo`) and always mounts `dsh-tui`; the `TerminalMode`/`resolveTerminalMode`/`ui.mode` seam is deleted. The bin refuses non-TTY streams **before booting the Loader** (a compose-time throw inside a Loader tree is logged per-entry, not rethrown, so a piped launch would otherwise settle into an idle UI-less process instead of exiting nonzero).
|
||||
- `examples/tui-agent/cordis.yml` now owns the coding composition inline (the include-patch inversion is gone); its Code Mode overlay includes its own base. `examples/cordis-agent` moved to the TUI app.
|
||||
- `examples/echo-agent` moved to the one-shot `dsh-cli-demo` app; `dsh-cli-demo` gained `-p/--prompt` as the flag form of the single task (mutually exclusive with the positional).
|
||||
- The UI-independent with-key coding e2es (`full-loop`, `coding-task`, `resume`, `compaction`, `todo-write`, `code-mode` and their shared harness) moved verbatim from `examples/repl-agent/tests/` to `examples/tui-agent/tests/` — they assemble the stack programmatically and never touched a UI.
|
||||
- The SDK wizard's `stdio` run interface became `tui` (`RunInterface = 'acp' | 'tui' | 'embed'`), contributing a `dsh-tui` entry instead of `dsh-stdio`; the generated `index.ts` guards TTY before `startSDK` for the same pre-boot fail-loud reason as the tui-demo bin.
|
||||
|
||||
### Testing policy: PTY only for the TUI
|
||||
|
||||
Pipes remain the default test medium. PTY-driven subprocess tests are sanctioned **only** where the subject is the TUI itself: `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` (which gained the Code Mode overlay boot scenario, replacing repl-agent's pipe smoke as the overlay's keyless composition proof) and the minimal PTY boot smoke in `examples/cordis-agent` (whose front door IS the TUI). Everything else moved to pipes over the one-shot bin:
|
||||
|
||||
- `examples/echo-agent/tests/echo.e2e.ts` proves the Loader boot + mock-model tool round-trip through `stream-json` records instead of readline transcript lines.
|
||||
- The CI demo-smoke gate (`scripts/run-gates.ts`, AGENTS.md) runs `demo:echo --output-format stream-json -p "echo ci smoke"` and parses the records structurally.
|
||||
- `packages/examples/tui-demo/tests/built-bin.e2e.ts` proves the built bin's piped-launch refusal (nonzero exit + pointer at `dsh-cli-demo`); the echo-round-trip-under-plain-Node and missing-config fail-loud proofs live in `cli-demo`'s built-bin suite.
|
||||
- `packages/context/time-context/tests/time-context.e2e.ts` runs one one-shot turn; multi-turn elapsed rendering stays unit-covered in its spec.
|
||||
|
||||
## Accepted losses
|
||||
|
||||
- **Piped multi-turn in one process** — the readline channel could script several turns over stdin; the one-shot bin runs one task per process. Multi-turn continuity is covered by `RESUME_SESSION_ID`/resume e2es and the TUI's scripted PTY conversation.
|
||||
- **Non-TTY `ask_user_question`** — the readline provider was the only non-TTY terminal implementation of `ctx.userInteraction`. A headless run whose model calls `ask_user_question` now fails that tool call (no provider); the ACP bridge remains the non-terminal provider. A future headless deployment that needs it composes its own provider.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep `dsh-stdio` as a pipe/automation channel without the repl demo** — rejected: its automation role duplicated `dsh-cli-demo` with a weaker contract (unstructured transcript, EOF-exit heuristics vs. one durable turn ending and format-pure output).
|
||||
- **Rewrite the piped smokes as PTY drivers** — rejected: PTY is the flakier, more complex medium and is reserved for the one surface pipes cannot prove (real TTY takeover/restore).
|
||||
|
||||
## Consequences
|
||||
|
||||
- One interactive front door (TUI), one automation front door (one-shot CLI), two servers; no mode-selection seam in the terminal app.
|
||||
- ~1,000 lines of readline unit tests deleted with their behavior; the readline transcript grammar is gone from all gates.
|
||||
- This supersedes the packaging half of [fold the stdio UI helper](2026-07-04-fold-stdio-ui-helper.md) (the folded package is now deleted) and amends the composition described in [the TUI front-door note](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md) (no `auto` selection; `tui-agent` owns the coding composition).
|
||||
@@ -0,0 +1,46 @@
|
||||
# Agent Note: 退役 readline 前端与 repl-agent 示例
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-retire-readline-front-door.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
仓库同时提供两个交互式终端前端:面向行的 readline 通道(`@deepseek-ai/dsh-stdio`)和全屏的 [`@deepseek-ai/dsh-tui`](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md)。TUI 落地之后,readline 的交互角色已经冗余——`demo:tui` 作为编码 agent 体验取代了 `demo:repl`——而它剩下的真实角色(管道与自动化)已由单次任务的 `@deepseek-ai/dsh-cli-demo` 应用以更好的方式承担(任务输入、DSH 原生 `text`/`json`/`stream-json` 输出、持久化、信号处理)。
|
||||
|
||||
这种重复是结构性的,不只是表面问题:`dsh-stdio-demo` 携带一个 `TerminalMode`(`auto`/`readline`/`tui`)选择接缝、约 1,000 行 readline 单元测试、一套被 CI 演示冒烟测试和两个 built-bin e2e 用 grep 匹配的 readline 文本记录语法(`[tool call] …` 行),以及一个倒置的示例组合:旗舰 `tui-agent` 叶节点被定义为对它所取代的 `repl-agent` 叶节点的 include patch。
|
||||
|
||||
## 决定
|
||||
|
||||
删除 readline 前端和 repl-agent 示例;只保留三类前端原型:**交互式 TUI**(仅 TTY,管道下快速失败)、**单次任务 CLI**(`-p`/位置参数任务,服务管道与自动化)以及**服务器**(ACP / JSON-RPC)。
|
||||
|
||||
- `packages/ui/stdio` 与 `examples/repl-agent` 已删除。`packages/examples/stdio-demo` 更名为 `@deepseek-ai/dsh-tui-demo`(`packages/examples/tui-demo`)并始终挂载 `dsh-tui`;`TerminalMode`/`resolveTerminalMode`/`ui.mode` 接缝随之删除。bin 在**启动 loader 之前**就拒绝非 TTY 流(Loader 树内组合期抛出的异常按条目记录日志而不会重新抛出,管道启动否则会沉降为一个空闲的无 UI 进程而不是以非零码退出)。
|
||||
- `examples/tui-agent/cordis.yml` 现在内联拥有编码组合(include patch 倒置消失);其 Code Mode 覆盖层 include 自己的基础配置。`examples/cordis-agent` 迁移到 TUI 应用。
|
||||
- `examples/echo-agent` 迁移到单次任务的 `dsh-cli-demo` 应用;`dsh-cli-demo` 新增 `-p/--prompt` 作为单个任务的旗标形式(与位置参数互斥)。
|
||||
- 与 UI 无关的带密钥编码 e2e(`full-loop`、`coding-task`、`resume`、`compaction`、`todo-write`、`code-mode` 及其共享 harness)原样从 `examples/repl-agent/tests/` 移入 `examples/tui-agent/tests/`——它们以编程方式组装整个栈,从不接触任何 UI。
|
||||
- SDK 向导的 `stdio` 运行接口改为 `tui`(`RunInterface = 'acp' | 'tui' | 'embed'`),贡献 `dsh-tui` 配置项而不是 `dsh-stdio`;生成的 `index.ts` 在 `startSDK` 之前检查 TTY,理由与 tui-demo bin 的启动前快速失败相同。
|
||||
|
||||
### 测试策略:PTY 仅用于 TUI
|
||||
|
||||
管道仍是默认测试介质。PTY 驱动的子进程测试**仅**在被测对象就是 TUI 本身时获准使用:`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`(新增 Code Mode 覆盖层启动场景,取代 repl-agent 的管道冒烟测试成为该覆盖层的无密钥组合证明)和 `examples/cordis-agent` 中最小的 PTY 启动冒烟测试(其前端就是 TUI)。其余全部改为通过单次任务 bin 走管道:
|
||||
|
||||
- `examples/echo-agent/tests/echo.e2e.ts` 通过 `stream-json` 记录证明 Loader 启动 + mock 模型的工具往返,而不是匹配 readline 文本记录行。
|
||||
- CI 演示冒烟门禁(`scripts/run-gates.ts`、AGENTS.md)运行 `demo:echo --output-format stream-json -p "echo ci smoke"` 并结构化解析记录。
|
||||
- `packages/examples/tui-demo/tests/built-bin.e2e.ts` 证明构建产物 bin 对管道启动的拒绝(非零退出 + 指向 `dsh-cli-demo` 的提示);纯 Node 下的 echo 往返证明与缺失配置的快速失败证明位于 `cli-demo` 的 built-bin 套件。
|
||||
- `packages/context/time-context/tests/time-context.e2e.ts` 运行一个单次任务轮次;多轮 elapsed 渲染仍由其单元测试覆盖。
|
||||
|
||||
## 接受的损失
|
||||
|
||||
- **单进程内的管道多轮对话**——readline 通道可以通过 stdin 脚本化多个轮次;单次任务 bin 每个进程只运行一个任务。多轮连续性由 `RESUME_SESSION_ID`/resume e2e 和 TUI 的脚本化 PTY 对话覆盖。
|
||||
- **非 TTY 的 `ask_user_question`**——readline 提供方是 `ctx.userInteraction` 唯一的非 TTY 终端实现。模型调用 `ask_user_question` 的 headless 运行现在会让该工具调用失败(没有提供方);ACP 桥接仍是非终端提供方。未来需要它的 headless 部署自行组合提供方。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **保留 `dsh-stdio` 作为纯管道/自动化通道而只删 repl 演示**——不予采纳:它的自动化角色以更弱的契约重复了 `dsh-cli-demo`(非结构化文本记录、EOF 退出的启发式判断,对比后者的一次持久轮次结束和格式纯净输出)。
|
||||
- **把管道冒烟测试改写为 PTY 驱动**——不予采纳:PTY 是更易波动、更复杂的介质,仅保留给管道无法证明的那一个表面(真实 TTY 的接管/恢复)。
|
||||
|
||||
## 后果
|
||||
|
||||
- 一个交互式前端(TUI)、一个自动化前端(单次任务 CLI)、两个服务器;终端应用不再有模式选择接缝。
|
||||
- 约 1,000 行 readline 单元测试随其行为一起删除;readline 文本记录语法从所有门禁中消失。
|
||||
- 本决定取代 [fold the stdio UI helper](2026-07-04-fold-stdio-ui-helper.md) 的打包部分(被折叠的包现已删除),并修订 [TUI 前端 Agent Note](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md) 描述的组合(不再有 `auto` 选择;`tui-agent` 拥有编码组合)。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user