refactor(cli): exclude tmux context and source guard

This commit is contained in:
Turtle
2026-07-29 21:15:48 +08:00
parent c1324ee896
commit 9e2c3d3093
47 changed files with 97 additions and 3153 deletions
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md
2026-07-27-tmux-location-context.md: b6f0b0cc85fa4808bd761f30bdbab8ad65e61717
2026-07-27-tmux-location-context.zh.md: e79214e03296a87c622df91437385950c00eded6
@@ -1,61 +0,0 @@
# Agent Note: tmux-location context
Status: implemented
English | [中文](2026-07-27-tmux-location-context.zh.md)
## Problem
An agent running inside tmux has no way to tell the model where it is: which session, window, and pane the process occupies, and how the window is laid out. A user directing several panes wants the model to orient itself to its own location so instructions like "the pane below" or "this window" resolve. The location must reach the model as durable, reconstructable context, not a system-prompt value rewritten in place, and must cost nothing when the location has not changed.
tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and `tmux display-message -t "$TMUX_PANE" -p '<format>'` prints any pane/window/session field. The open question was how to observe it — pull on each preparation, or push from a tmux hook — and how to avoid a per-step token cost and hidden process-local state.
## Decision
`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy.
**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design.
**Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider.
**Detect a real pane by tty, not by `$TMUX_PANE` alone.** `$TMUX_PANE` is inherited: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) carries `$TMUX`/`$TMUX_PANE` from that ancestor even though the process does not live in that pane, which otherwise injects a stale, wrong location. The command resolves this process's controlling terminal with `ps -o tty= -p <pid>` (the agent's own pid, passed in-process) and compares it to the pane's `#{pane_tty}`; fields are emitted only on a match. A genuine pane owns this process's tty; an inherited environment names some other pane's tty and reads as "not in tmux". Checking `$TMUX` instead does not help — it is inherited identically. This is the definitive discriminator and needs no allowlist of terminal emulators.
**Own location and layout only.** The queried fields are session name, window index/name, pane index/id, window/pane active flags, and `window_layout`. Pane and window pixel sizes are excluded (layout tree conveys structure; sizes are noisy and change on every terminal resize). Sibling-pane contents are never captured (`capture-pane`), keeping the reading small and avoiding scraping unrelated, possibly sensitive, output.
**Inject only on change, with optional interval floor.** When due, the plugin calls `agent.inject()` for one `user/message` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression compares the rendered state block (everything after the turn preamble line) against the latest injection of this source, found by scanning raw durable session events — so the schedule survives compaction and process resume without a process-local cache. The optional `refreshIntervalMs` (manually validated as a non-negative safe integer at plugin load) additionally suppresses injections within that window of the latest one.
### Text
```text
tmux location (turn <turn>):
session <session>, window <index> "<name>", pane <index> <pane-id>
window active=<0|1>, pane active=<0|1>, layout <window-layout>
```
The turn preamble is the volatile first line; the two-line state block below it is the unit compared for change suppression, so re-injection is driven by tmux state, not loop position.
### Durability and request reconstruction
Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback.
The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests.
## Consequences
An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. Deployments opt in through cordis.yml; the default spine and shipped examples stay silent. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one bash execution (through the sandboxed bash seam) on the first step of each turn that is due — internally a `ps` tty probe, a `tmux display-message` tty query, and the field query. Only the optional interval floor suppresses the query itself; an unchanged location is known only after querying, so it suppresses the injection alone.
## Testing
Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%.
## Alternatives considered
- **Push from a tmux hook / background watcher** — rejected: requires installing hooks in the user's tmux and a background process with teardown, to gain mid-step freshness that per-turn context does not need.
- **Run every step** — rejected: location is stable within a turn; re-querying adds token cost without new information. Gating on `step === 1` yields per-turn readings.
- **Raw `child_process`** — rejected: bypasses the sandbox/policy seam and hand-rolls subprocess code the `ctx.bash` executor already owns.
- **Include pane/window pixel sizes** — rejected: sizes churn on every resize and add noise; the layout tree already conveys structure.
- **Scrape sibling panes with `capture-pane`** — rejected: large, noisy, and privacy-sensitive; out of scope for "own location".
- **Dynamic system-prompt section** — rejected: replacing a value erases the earlier readings behind prior reasoning and is not reconstructable; one durable attributed message records each location where it became visible.
- **Trust `$TMUX_PANE` (or `$TMUX`) presence** — rejected: both are inherited by terminals launched from a tmux shell (VS Code integrated terminal), so a non-pane process injects a stale location. The pane `#{pane_tty}` vs. this process's controlling tty is the definitive check.
- **Denylist known terminal emulators (e.g. `TERM_PROGRAM=vscode`)** — rejected: a partial, ever-growing list that still misses other launchers; the tty match is exact and launcher-agnostic.
- **A runtime invariant validating each reading's turn, position, and format** — shipped initially, then removed: it re-derived the producer's own scheduling from the log and asserted a regex over text the same package had just rendered, so it restated `apply()` rather than checking an independent relation. Every failure it could report required an edit to this package, which its pipeline tests already catch. Reintroduce a companion check only for a relation the plugin does not itself compute — for example if readings gain cross-turn ordering or enclosure obligations that another package can violate.
@@ -1,61 +0,0 @@
# Agent Notetmux 位置上下文
Status: implemented
[English](2026-07-27-tmux-location-context.md) | 中文
## 问题
运行在 tmux 内的 agent 无法告诉模型自己身在何处:进程占据哪个 session、window、pane,以及 window 如何布局。当用户操作多个 pane 时,希望模型能对自身位置有所定位,从而让"下方的 pane""这个 window"之类的指令得以解析。位置必须以持久、可重建的上下文形式送达模型,而非在原地被改写的系统提示值,并且当位置未变化时不产生任何成本。
tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所在 pane`tmux display-message -t "$TMUX_PANE" -p '<format>'` 可打印任意 pane/window/session 字段。待决问题在于如何观测——在每次准备时拉取,还是由 tmux hook 推送——以及如何避免逐步骤 token 成本与隐藏的进程内状态。
## 决策
`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。
**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。
**通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。
**以 tty 判定真实 pane,而非仅凭 `$TMUX_PANE`。** `$TMUX_PANE` 会被继承:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程带上 `$TMUX``$TMUX_PANE`,即使进程并不位于那个 pane 中,否则就会注入一个陈旧且错误的位置。命令用 `ps -o tty= -p <pid>`(在进程内传入 agent 自身的 pid)解析本进程的控制终端,并与 pane 的 `#{pane_tty}` 比较;只有匹配时才输出字段。真正的 pane 拥有本进程的 tty;继承而来的环境指向的是另一个 pane 的 tty,因而被读作"不在 tmux 中"。改为检查 `$TMUX` 也无济于事——它同样会被继承。这是决定性的判别依据,且无需维护终端模拟器名单。
**仅自身位置与布局。** 查询字段为 session name、window index/name、pane index/id、window/pane 活动标志以及 `window_layout`。省略 pane 与 window 像素尺寸(布局树已传达结构;尺寸嘈杂且每次终端缩放都会变化)。从不采集相邻 pane 内容(`capture-pane`),使读数保持小巧,并避免抓取无关、可能敏感的输出。
**仅在变化时注入,并可选间隔下限。** 需要时,插件调用 `agent.inject()` 注入一条来源为 `{ kind: 'plugin', plugin: 'tmux-context' }``user/message`。变化抑制将渲染出的状态块(轮次前缀行之后的全部内容)与该来源的最近一次注入比较,后者通过扫描原始持久会话事件获得——因此调度可跨压缩与进程恢复存续,无需进程内缓存。可选的 `refreshIntervalMs`(在插件加载时手动校验为非负安全整数)会额外抑制距最近一次注入不足该窗口的注入。
### 文本
```text
tmux location (turn <turn>):
session <session>, window <index> "<name>", pane <index> <pane-id>
window active=<0|1>, pane active=<0|1>, layout <window-layout>
```
轮次前缀是易变的首行;其下的两行状态块才是变化抑制所比较的单元,因此重新注入由 tmux 状态驱动,而非循环位置。
### 持久性与请求重建
每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。
发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。
## 后果
启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX``$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 bash 执行(经沙箱化的 bash seam)——内部包含一次 `ps` tty 探测、一次 `tmux display-message` tty 查询和字段查询。只有可选的间隔下限会抑制查询本身;位置是否变化只有在查询之后才知道,因此它只抑制注入。
## 测试
单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}``ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。
## 考虑过的替代方案
- **由 tmux hook / 后台监视器推送**——否决:需要在用户的 tmux 中安装 hook,并引入带清理的后台进程,只为换取按轮次上下文并不需要的步内新鲜度。
- **每个 step 都运行**——否决:位置在一轮内稳定;重复查询只增加 token 成本而无新信息。以 `step === 1` 为门槛得到按轮次读数。
- **裸 `child_process`**——否决:绕过沙箱/策略 seam,并手写 `ctx.bash` 执行器已拥有的子进程代码。
- **包含 pane/window 像素尺寸**——否决:尺寸每次缩放都变动、徒增噪声;布局树已传达结构。
- **用 `capture-pane` 抓取相邻 pane**——否决:庞大、嘈杂且涉及隐私;超出"自身位置"范围。
- **动态系统提示区块**——否决:替换某个值会抹去支撑先前推理的历史读数且不可重建;单条持久且带来源的消息在每个位置变得可见时予以记录。
- **信任 `$TMUX_PANE`(或 `$TMUX`)存在即可**——否决:两者都会被从 tmux shell 启动的终端(VS Code 集成终端)继承,于是非 pane 进程会注入陈旧位置。pane 的 `#{pane_tty}` 与本进程控制终端的比对才是决定性检查。
- **对已知终端模拟器设黑名单(如 `TERM_PROGRAM=vscode`)**——否决:名单不完整且会不断增长,仍会漏掉其他启动器;tty 比对精确且与启动器无关。
- **用运行时 invariant 校验每条读数的轮次、位置与格式**——最初随包发布,随后移除:它从日志中重新推导生产者自身的调度,并对同一个包刚刚渲染出的文本断言正则,因此只是重述 `apply()`,而非检查一条独立关系。它能报出的每种失败都必须先修改本包,而这些本包的管线测试已经覆盖。仅当出现插件自身并不计算的关系时才重新引入伴生检查——例如读数将来具备可被其他包破坏的跨轮次顺序或包裹义务。
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md
2026-07-28-source-guard-staging-edit-gate.md: 8452006190166c783efafc398566ef7f4da10323
2026-07-28-source-guard-staging-edit-gate.zh.md: 83589ce833e4aa74968b40247848685a1e030f6b
@@ -1,76 +0,0 @@
# Agent Note: source-guard denies direct staging-checkout edits
Status: implemented
English | [中文](2026-07-28-source-guard-staging-edit-gate.zh.md)
## Problem
The [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill governs every personal change to a dsh source checkout: implement in a task worktree branched from the staging tip, then integrate under `.agents/merge.lock`. Its central rule is negative — do not edit the personal staging checkout directly — and a negative rule delivered only as prompt text fails in exactly the case that matters. An agent that never loads the skill never sees the rule, and one that loads it early can still forget it thirty tool calls later. The failure is silent and expensive: commits land on the staging branch that the launcher runs from, outside any task branch, with no lock held and no rollback worktree.
Prompt guidance cannot fix this, because the guidance is what went unread. The rule needs an enforcement point.
## Decision
`@deepseek-ai/dsh-source-guard` (`packages/guard/source-guard/`) is a `tools/pre-execute` listener that returns `{kind: 'deny', reason}` for a `write` or `edit` whose target resolves inside a protected staging worktree, unless the calling session's durable log already records a successful `skill` call naming `dsh-customize`. It registers no service and contributes no prompt text or tool schema; an allowed call is indistinguishable from one made without the plugin. It is not in any shipped default composition.
### Git identity from files, not a path prefix and not `git`
Whether a path is protected is decided by reading `.git`, its `gitdir:` pointer, and `HEAD`. Three shapes resolve: a plain clone (`.git` is a directory that is its own common dir), a linked worktree (`.git` is a file pointing at `<common>/worktrees/<name>`, whose common dir is two levels up), and a detached HEAD (`HEAD` holds a raw object id and names no branch). A `gitdir:` pointer resolves whether absolute — what `git worktree add` writes — or relative, which git resolves against the worktree directory holding it.
Denial requires the target's worktree to match the launcher's on both identities: the same shared git directory and the same branch. Both come from resolving `protectedCheckout`, so nothing about the protected branch is configured. An earlier revision matched a `dsh-staging/*` name pattern instead; the exact-branch rule replaced it because a pattern is wrong in both directions. It denied every sibling staging worktree an old install had left behind, none of which runs a launcher, and it silently protected nothing for a maintainer whose staging branch follows no naming convention — a fatal property for a shipped default that must hold for checkouts [`scripts/install.sh`](../../../../scripts/install.sh) did not create.
A path-prefix rule would have been wrong, not merely imprecise. The task worktrees the skill prescribes live *inside* the protected tree at `<staging>/.worktrees/...`, so a prefix rule would deny every edit the workflow requires. Resolution walks outward from the target and stops at the first enclosing worktree, so it reports the innermost one: a nested task worktree answers with its own task branch and is allowed, while the launcher's own tree answers with the launcher's branch and is denied.
Two path details decide whether the gate holds at all, and both are enforcement, not polish. Repository identity is compared on symlink-resolved paths (`canonicalPath` from `dsh-sandbox`), because a session cwd under `/var/...` and a configured path under `/private/var/...` are the same macOS directory and a lexical comparison would fail open on every write. And a relative `file_path` is resolved against the calling session's workspace, exactly as `dsh-tool-fs` resolves it; judging only absolute paths would have left a relative path as an unguarded route to a protected file.
`protectedCheckout` names a path inside the guarded checkout, defaulting to this module's own file. That resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. A harness running from an installed copy resolves a different repository, or none, and guards nothing; the rule is meaningless outside a source checkout.
The shipped TUI composition loads the plugin with these defaults, so every source install is protected without configuration. It is inert for an ordinary project: a workspace in another repository, or none, never matches the launcher's identities.
### Satisfaction replayed from the durable log
The gate lifts on a `tool/call` naming the `skill` tool whose arguments parse to `{name: <requiredSkill>}`, paired by call id with a non-error `tool/result`. Both fields are already durable (`packages/core/session/src/types.ts`), so this needs no new session event and no coupling to skill-provider internals.
The log is the only state. In-memory satisfaction (the `WeakMap` shape [`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) uses for its chains) would be smaller, but it loses satisfaction on resume: a resumed session that already read the skill would be told to read it again, and the denial would look like a bug rather than a rule. Replay costs a scan bounded by the first hit and buys resume correctness.
### Fail open, deliberately
A path outside any worktree, a detached HEAD, a foreign repository, a malformed `gitdir:` pointer, and unreadable metadata all leave the call to the rest of the chain. The alternative — denying whenever git identity is unavailable — converts any `.git` permission problem into a harness that cannot write files at all. The guard exists to prevent one specific, recoverable mistake; it must not become a larger outage than the mistake.
### Narrow scope
`read` is never gated: inspecting staging violates nothing, and the skill explicitly permits read-only questions. `bash` is not gated either. Reliably classifying mutating shell commands is a matcher problem with no honest completion condition, so a determined model can still change staging through a shell. This is a boundary against forgetting, not a sandbox against intent.
## Alternatives considered
- **Advisory reminder instead of denial** (`additionalContexts` on `tools/post-execute`, the `repeat-tool-guard` shape). Rejected: the write has already happened when the reminder arrives, so the violation is committed and the guidance is again just text.
- **`{kind: 'ask'}` routed to approval.** Rejected: it prompts on every legitimate task-worktree edit in the common case, and degrades to denial in a composition without approval support, making behavior depend on unrelated plugins.
- **Running `git rev-parse` through `ctx.subprocess`.** Rejected after measuring the alternative: two file reads answer the same question with no process spawn per gated write, no `git` on `PATH` requirement, and no subprocess dependency. Reading `.git` and `HEAD` is a stable on-disk format, not an implementation detail.
- **Explicit `protectedRoots` config with no detection.** Rejected: it makes the common case require configuration to be correct, and a stale absolute path silently disables protection.
- **A configurable staging-branch name pattern** (`stagingBranchPatterns`, default `dsh-staging/*`). Shipped first, then removed: it protects the wrong set in both directions — every stale sibling worktree that runs no launcher, and nothing at all for a maintainer whose branch is named otherwise. Deriving the branch from the launcher needs no configuration and cannot be misconfigured.
- **Auto-detecting the checkout with no override.** Rejected: the detection is a default, not a law; a deployment guarding a different checkout, or running from an installed copy, needs the explicit value.
- **Denying everything under the checkout root, `.worktrees/` included.** Rejected: it blocks the workflow the skill prescribes, so the guard would fire on every legitimate task edit.
- **Gating `bash` with a mutating-command matcher.** Deferred, not rejected: worth revisiting if bypasses are observed in practice. A matcher that is wrong in either direction is worse than an honestly narrow gate.
## Consequences
The rule now holds without depending on the model having read it, and the denial names the path, the branch, and the skill, so the model's next action is determined rather than guessed. Enforcement sits at the operation boundary that owns the decision, so it cannot be bypassed by prompt filtering or listener order.
Shipping it in the TUI default means every source install is protected without configuration, and the protection follows the launcher across upgrades because the branch is derived rather than named. The cost of that reach is that the plugin loads for every user, including those whose workspace it can never match.
What it cost otherwise: the guard is only as complete as its tool list, and `bash` remains open. Worktree identity is cached per directory for the plugin's lifetime, so a mid-session branch switch is not observed on either side. Only the launcher's own checkout is protected, so a stale sibling stays editable. Loading the skill lifts the gate for the whole session without verifying the workflow was actually followed — the gate proves the instructions were read, not obeyed. Satisfaction is per session, so a subagent with its own session must load the skill itself.
## Testing
Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to one repository, a malformed pointer, and unreadable metadata — covering both source files to per-file 100%. A companion `invariant.ts` validates the durable denial's shape, since the refusal text is the package's only model-visible output and is actionable only when it names the path, branch, and skill.
The real-composition smoke boots `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` through the Loader and the headless app, and asserts three things about the assembled run: the tool result is an error, its text is the exact denial, and the targeted file still holds its original bytes — enforcement before dispatch, not advice after it.
An ACP snapshot scenario (`source-guard-staging-deny`) originally owned the assembled transcript, seeding a staging worktree in the harness's generated cwd through a new `Scenario.prepareCwd` hook — git never tracks an entry named `.git` and `.gitignore` excludes every `worktrees/` directory, so the fixture committed the two `HEAD` bodies and the hook assembled the real layout. Authoring it paid for itself immediately: it exposed both path defects above (the transcript showed `fs-policy` answering first wherever the guard had quietly declined to judge) and then caught its own first fixture, whose ignored `worktrees/` path passed locally from an untracked file. The scenario was later removed with the assembled-run evidence consolidated into the Loader-composition smoke; the `prepareCwd` hook it introduced remains part of the snapshot harness for repository-shaped fixtures.
## Related
- [The personal-staging maintenance skills Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md) — the workflow this gate enforces one rule of. That note owns the skills' content and discovery; this one owns the enforcement point and holds no authority over the workflow itself.
- [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary this gate's denial uses.
- [The repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md) — the sibling guard whose advisory shape this one deliberately does not take.
@@ -1,76 +0,0 @@
# Agent Note: source-guard 拒绝直接编辑 staging 检出目录
Status: implemented
[English](2026-07-28-source-guard-staging-edit-gate.md) | 中文
## Problem
[`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill(技能)规范对 dsh 源码检出的每项个人变更:先在从 staging 分支顶端分出的任务 worktree 中实现,再于 `.agents/merge.lock` 保护下完成集成。它的核心规则是一条禁令:不得直接编辑个人 staging 检出目录;而若只通过提示词文本传达禁令,它恰好会在最要紧的场景中失效。从未加载该 skill 的 agent(智能体)根本看不到规则;即便尽早加载,也仍可能在三十次工具调用后将其忘掉。这种失败既静默又代价高昂:提交会落在启动器实际运行的 staging 分支上,不属于任何任务分支,既未持有锁,也没有用于回滚的 worktree。
提示词指导无法解决这个问题,因为未被阅读的正是这些指导。该规则需要一个强制执行点。
## Decision
`@deepseek-ai/dsh-source-guard``packages/guard/source-guard/`)是一个 `tools/pre-execute` 监听器;它会返回 `{kind: 'deny', reason}`,拒绝目标解析到受保护 staging worktree 内的 `write``edit`,除非调用会话的持久日志已经记录过一次成功的 `skill` 调用,且名称为 `dsh-customize`。它不注册服务,也不贡献提示词文本或工具 schema;获准的调用与未加载该插件时的调用没有区别。任何已交付的默认组合都不包含它。
### 从文件而非路径前缀或 `git` 判定 Git 身份
系统通过读取 `.git`、其中的 `gitdir:` 指针以及 `HEAD` 来判断路径是否受保护。它可以解析三种形态:普通克隆(`.git` 是目录,且自身就是共享目录)、链接 worktree(`.git` 是文件,指向 `<common>/worktrees/<name>`,其共享目录位于上两级)以及 HEAD 分离状态(`HEAD` 保存原始对象 id,不指向任何分支)。`gitdir:` 指针无论是绝对路径(`git worktree add` 写入的形式)还是相对路径都可以解析;Git 会以包含该指针的 worktree 目录为基准解析相对路径。
只有目标的 worktree 在两项身份上都与启动器的 worktree 匹配时才会拒绝:共用同一个共享 Git 目录,且分支相同。这两项身份均通过解析 `protectedCheckout` 得出,因此无需配置受保护分支的任何信息。较早版本则匹配 `dsh-staging/*` 名称模式;现已改用确切分支规则,因为模式会在两个方向上出错。它会拒绝旧安装留下的每一个同级 staging worktree,尽管其中没有任何一个运行着启动器;对于 staging 分支不遵循任何命名约定的维护者,它又会静默地完全不提供保护——而已交付的默认配置必须在 [`scripts/install.sh`](../../../../scripts/install.sh) 未创建的检出目录上也能生效,这一属性是致命的。
路径前缀规则不仅不精确,而且本身就是错误的。该 skill 规定的任务 worktree 位于受保护树*内部*的 `<staging>/.worktrees/...`,因此前缀规则会拒绝工作流要求的每一次编辑。解析过程从目标向外逐层查找,遇到第一个所属 worktree 时停止,因此返回最内层的 worktree:嵌套的任务 worktree 会返回自身的任务分支并获准,而启动器自身所在的树会返回启动器的分支并被拒绝。
有两个路径细节决定门禁究竟能否生效,二者都是强制执行要求,而非细节润色。仓库身份会按解析符号链接后的路径进行比较(使用 `dsh-sandbox``canonicalPath`),因为位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径在 macOS 上是同一个目录,若按路径字符串比较,每次写入都会故障放行(fail-open)。此外,相对 `file_path` 会完全按照 `dsh-tool-fs` 的方式,相对于调用会话的工作区解析;若只判断绝对路径,相对路径就会成为绕过门禁访问受保护文件的路径。
`protectedCheckout` 指定受保护检出目录内的一条路径,默认值为本模块自身的文件。由此解析出运行中 harness 的启动来源检出目录——当前运行的部署,无论其分支采用什么名称。若 harness 从已安装副本运行,解析出的会是另一个仓库或没有仓库,因此不会保护任何内容;该规则在源码检出之外没有意义。
已交付的 TUI 组合会以这些默认值加载插件,因此每个源码安装无需配置即可受到保护。对于普通项目,它不会生效:若工作区位于其他仓库中,或不存在工作区,就绝不会匹配启动器的身份。
### 从持久日志回放满足状态
如果日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: <requiredSkill>}`,且按调用 id 能配对到非错误的 `tool/result`,门禁即解除。二者都已持久化(`packages/core/session/src/types.ts`),因此无需新增会话事件,也不与 skill 提供方内部实现耦合。
日志是唯一状态。在内存中记录满足状态(`WeakMap` 结构,[`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) 将其用于调用链)所需实现会更小,但恢复后满足状态会丢失:一个已经读取过该 skill 的恢复会话会被要求再次读取,而这次拒绝看起来会像缺陷而不是规则。回放的代价是扫描日志,但首次命中即停止,并换来恢复行为正确。
### 刻意采用故障放行
目标路径不在任何 worktree 内、HEAD 分离、属于其他仓库、`gitdir:` 指针格式错误或元数据不可读时,调用都会交给调用链的其余部分处理。反过来,只要无法判定 Git 身份就拒绝,会让任何 `.git` 权限问题都导致 harness 完全无法写文件。该 guard 旨在防止一种特定且可恢复的错误,不得造成比该错误更严重的故障。
### 范围收窄
`read` 从不受门禁限制:检查 staging 不会违反任何规则,而且该 skill 明确允许只读提问。`bash` 同样不受门禁限制。要可靠判定哪些 shell 命令会修改状态,需要构造一个无法给出可信完备标准的匹配器,因此执意修改的模型仍可通过 shell 修改 staging。这是一道防止遗忘的边界,不是阻止刻意操作的沙箱。
## Alternatives considered
- **用建议性提醒代替拒绝**(使用 `additionalContexts`,挂载在 `tools/post-execute` 上,采用 `repeat-tool-guard` 的形态)。不予采纳:提醒到达时写入已经发生,违规已成事实,而指导又一次沦为纯文本。
- **将 `{kind: 'ask'}` 交给审批。** 不予采纳:在常见场景中,它会对任务 worktree 内每次合法编辑都发起询问;在没有审批支持的组合中还会退化为拒绝,使行为取决于无关插件。
- **运行 `git rev-parse`,并通过 `ctx.subprocess` 执行。** 对替代方案进行实测后不予采纳:读取两个文件即可回答同一问题,每次受门禁限制的写入都无需 spawn 进程,不要求 `git` 存在于 `PATH` 中,也不依赖子进程。读取 `.git``HEAD` 所依据的是稳定的磁盘格式,而非实现细节。
- **显式配置 `protectedRoots`,不做检测。** 不予采纳:这会让常见场景的保护效果依赖配置正确性,而陈旧的绝对路径会静默禁用保护。
- **可配置的 staging 分支名称模式**(`stagingBranchPatterns`,默认 `dsh-staging/*`)。最初随产品交付,随后删除:它从两个方向划错了保护范围——既纳入每个不运行启动器的陈旧同级 worktree,又完全不保护分支另有名称的维护者。由启动器派生分支无需配置,也不可能配置错误。
- **自动检测检出目录,不提供覆盖项。** 不予采纳:检测只是默认行为,而非不可更改的规定;若部署要保护另一个检出目录,或自身从已安装副本运行,就需要显式值。
- **拒绝检出根目录下的一切操作,包括 `.worktrees/`。** 不予采纳:这会阻断该 skill 规定的工作流,让 guard 在每次合法任务编辑时触发。
- **用修改类命令匹配器把守 `bash`。** 推迟而非否决:如果实际观察到绕过行为,值得重新考虑。任一方向判断错误的匹配器,都不如如实限定范围的门禁。
## Consequences
如今,该规则无需依赖模型已经读过它也能生效;拒绝理由会列出路径、分支与 skill,让模型的下一步操作明确,无需猜测。强制执行位于拥有该决策的操作边界,因此提示词过滤或监听器顺序都无法绕过它。
将其纳入 TUI 默认组合意味着每个源码安装无需配置即可受到保护;由于分支是派生而非按名称指定,保护会在升级时跟随启动器。这种覆盖范围的代价是插件会为每位用户加载,包括工作区永远不可能匹配启动器身份的用户。
除此之外的代价是:guard 的完整程度受限于其工具列表,`bash` 仍保持开放。worktree 身份在插件生命周期内按目录缓存,因此无法观察到任一侧在会话中途切换分支。只保护启动器自身的检出目录,因此陈旧的同级检出目录仍可编辑。加载该 skill 会为整个会话解除门禁,却不会验证工作流是否确实得到遵循——门禁只能证明指令已被阅读,不能证明已被执行。满足状态按会话隔离,因此拥有独立会话的 subagent 必须自行加载该 skill。
## Testing
单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖一个 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径、格式错误的指针以及不可读元数据,使两个源码文件都达到逐文件 100% 覆盖率。配套的 `invariant.ts` 会验证持久拒绝的结构,因为拒绝文本是该包唯一面向模型的输出,且只有其中列出路径、分支和 skill 时才具有可操作性。
真实组合冒烟测试通过 Loader 与 headless 应用启动 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`,并对组装后的运行断言三项事实:工具结果是错误、文本与拒绝理由逐字一致、目标文件仍保留原始字节。这证明系统在分发前强制执行规则,而不是事后给出建议。
一个 ACPAgent Client Protocol)快照场景(`source-guard-staging-deny`)最初负责组装后的 transcript(文本记录),通过新的 `Scenario.prepareCwd` 钩子在 harness 生成的 cwd 中植入 staging worktree——Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此 fixture 提交两个 `HEAD` 的内容,由钩子组装真实布局。编写它立刻证明了投入的价值:它暴露了上述两个路径缺陷(transcript 显示每当 guard 悄然不作判断时 `fs-policy` 都会率先响应),随后又发现了自身首版 fixture 的问题——被忽略的 `worktrees/` 路径因未跟踪文件而在本地通过。该场景后来被移除,组装运行证据合并进 Loader 组合冒烟测试;它引入的 `prepareCwd` 钩子仍留在快照 harness 中,服务于仓库形态的 fixture。
## Related
- [个人 staging 维护 skill 的 Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md):本门禁负责执行该工作流的一条规则。对方 Agent Note 负责这些 skill 的内容与发现机制;本文只负责强制执行点,对工作流本身不具有定义权。
- [拦截 seam Agent Note](2026-06-30-interception-seams.md):本门禁拒绝时使用的 `tools/pre-execute` `allow`/`deny`/`ask` 词汇。
- [repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md):同类 guard;本文刻意不采用其建议性形态。
-2
View File
@@ -76,7 +76,6 @@
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-source-guard": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
"@deepseek-ai/dsh-spill-policy": "workspace:^",
"@deepseek-ai/dsh-storage": "workspace:^",
@@ -89,7 +88,6 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-tmux-context": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
-3
View File
@@ -97,9 +97,6 @@
# Refuses write/edit inside the dsh checkout this launcher runs from, on that
# checkout's own branch, until the session loads dsh-customize. Inert
# everywhere else, so an ordinary project sees no change.
- id: source-guard
name: '@deepseek-ai/dsh-source-guard'
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
+58 -69
View File
@@ -108,7 +108,7 @@ export interface Config {
Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md)
Source: [`packages/core/agent-loop/src/index.ts:211`](../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loop/src/index.ts)
## `@deepseek-ai/dsh-agent-spine-demo`
@@ -1153,26 +1153,6 @@ export interface Config {
Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts)
## `@deepseek-ai/dsh-session-registry-file`
```ts config-catalog
/**
* Plugin config as callers write it: `root` is required — a cwd fallback would
* scatter registries — while the lock tunables are optional because
* `static Config` supplies their defaults.
*/
export interface Config {
/** Directory holding the registry file; created `0o700` on demand. */
root: string
/** Milliseconds after which a held lock is considered abandoned and reclaimed. */
lockStaleMs?: number
/** Retries before a contended acquisition fails loud. */
lockRetries?: number
}
```
Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts)
## `@deepseek-ai/dsh-session-telemetry-otel`
Requires: `sessions`
@@ -1283,38 +1263,6 @@ export interface Config {
Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts)
## `@deepseek-ai/dsh-source-guard`
Requires: `fs`
```ts config-catalog
/**
* Plugin config, validated by the same-named schemastery schema plus the
* load-time checks in `apply` (misconfiguration fails loud: an empty `tools`
* list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at
* plugin load, never a silent fall-back).
*/
export interface Config {
/** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */
requiredSkill?: string
/** Tool names to gate (default `['write', 'edit']`). */
tools?: string[]
/**
* Absolute path inside the checkout this guard protects. Its worktree
* supplies BOTH protected identities: the repository (targets in any other
* repository are ignored) and the exact branch (only that branch's worktree
* is protected). Defaults to this module's own location, which resolves the
* checkout the running harness was launched from — the live deployment,
* whatever its branch is named. Set it explicitly to guard a different
* checkout, or when the harness runs from an installed copy whose own
* location is not a checkout at all.
*/
protectedCheckout?: string
}
```
Source: [`packages/guard/source-guard/src/index.ts:30`](../packages/guard/source-guard/src/index.ts)
## `@deepseek-ai/dsh-spill-local`
```ts config-catalog
@@ -1593,20 +1541,6 @@ export interface Config {
Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts)
## `@deepseek-ai/dsh-tmux-context`
Requires: `agents`
```ts config-catalog
/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */
export interface Config {
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */
refreshIntervalMs?: number
}
```
Source: [`packages/context/tmux-context/src/index.ts:33`](../packages/context/tmux-context/src/index.ts)
## `@deepseek-ai/dsh-token-meter`
```ts config-catalog
@@ -2005,6 +1939,63 @@ export interface TuiThemeConfig {
Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts)
## `@deepseek-ai/dsh-tui-demo`
```ts config-catalog
/** App config routed to the spine, TUI, configured agent, and JSONL backend. */
export interface Config {
/** Provider route for the `main` agent. */
provider: string
/** Model name for the `main` agent; a matching adapter must be registered. */
model: string
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
maxParallelToolCalls?: number
/** Deployment persona forwarded to the system-prompt plugin. */
persona?: string
/** Explicit model-facing tool order forwarded to the system-prompt plugin. */
toolOrder?: string[]
/** Tool-registry presentation config forwarded through agent-spine-demo. */
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
/** Cross-session reference discovery and snapshot byte budgets. */
sessionReferences?: SessionReferenceConfig
/** TUI transcript's optional first line; absent renders nothing on start. */
welcome?: string
/**
* Shell command template the TUI prints on exit and lists under `/resume`,
* with `{session}` replaced by the live session id (forwarded to the front
* door). Set it to a command that resumes the session, e.g.
* `dsh --resume {session}`.
*/
resumeCommand?: string
/** Full-screen TUI presentation settings. */
ui?: uiTui.TuiConfig
/** Skill registry, local-provider, and model-facing consumer config. */
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-spine-demo. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
/** Persisted session id to resume instead of creating a fresh session. */
resumeSessionId?: string
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
}
```
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts)
Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts)
## `@deepseek-ai/dsh-user-approval`
```ts config-catalog
@@ -2240,7 +2231,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts))
- `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts))
- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts))
- `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
- `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts))
@@ -2263,7 +2253,6 @@ Abstract service classes — a deployment loads a concrete implementation packag
- `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
- `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts))
- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts))
- `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts))
- `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts))
- `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
+7 -7
View File
@@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
@@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) |
| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) |
@@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
@@ -50,7 +50,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`source-guard`](../packages/guard/source-guard), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
@@ -65,7 +65,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| --- | --- | --- |
| `commands/changed` | `runtime` (`emit`) | `ui-command` |
| `connection/reset` | `runtime` (`emit`) | `ui-command` |
| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`source-guard`](../packages/guard/source-guard), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `hmr`, `modules`, `webserver` |
| `internal/status` | - | [`agent`](../packages/core/agent) |
| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` |
+20 -33
View File
@@ -173,7 +173,6 @@ flowchart TD
subgraph group_context["packages/context"]
pkg_session_reference["session-reference"]
pkg_time_context["time-context"]
pkg_tmux_context["tmux-context"]
pkg_workspace_context["workspace-context"]
end
subgraph group_examples["packages/examples"]
@@ -181,10 +180,10 @@ flowchart TD
pkg_agent_spine_demo["agent-spine-demo"]
pkg_cli_demo["cli-demo"]
pkg_jsonrpc_demo["jsonrpc-demo"]
pkg_tui_demo["tui-demo"]
end
subgraph group_guard["packages/guard"]
pkg_repeat_tool_guard["repeat-tool-guard"]
pkg_source_guard["source-guard"]
end
subgraph group_host["packages/host"]
pkg_host_apiproxy["host-apiproxy"]
@@ -222,11 +221,6 @@ flowchart TD
pkg_session_projection["session-projection"]
pkg_session_projection_cache["session-projection-cache"]
end
subgraph group_session_registry["packages/session-registry"]
pkg_session_registry["session-registry"]
pkg_session_registry_file["session-registry-file"]
pkg_session_registry_live["session-registry-live"]
end
subgraph group_storage["packages/storage"]
pkg_storage["storage"]
pkg_storage_domain["storage-domain"]
@@ -456,9 +450,6 @@ flowchart TD
pkg_sandbox_policy --> pkg_session
pkg_session_projection --> pkg_invariants
pkg_session_projection --> pkg_session
pkg_session_registry --> pkg_brand
pkg_session_registry --> pkg_invariants
pkg_session_registry --> pkg_session
pkg_llm_retry --> pkg_agent
pkg_llm_retry --> pkg_invariants
pkg_llm_retry --> pkg_llm
@@ -532,10 +523,6 @@ flowchart TD
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
pkg_time_context --> pkg_session
pkg_tmux_context --> pkg_agent
pkg_tmux_context --> pkg_bash
pkg_tmux_context --> pkg_invariants
pkg_tmux_context --> pkg_session
pkg_pty --> pkg_agent
pkg_pty --> pkg_brand
pkg_pty --> pkg_invariants
@@ -546,9 +533,6 @@ flowchart TD
pkg_session_projection_cache --> pkg_session_persistence
pkg_session_projection_cache --> pkg_session_projection
pkg_session_projection_cache --> pkg_storage_domain
pkg_session_registry_file --> pkg_invariants
pkg_session_registry_file --> pkg_session
pkg_session_registry_file --> pkg_session_registry
pkg_tasks --> pkg_agent
pkg_tasks --> pkg_brand
pkg_tasks --> pkg_invariants
@@ -630,10 +614,6 @@ flowchart TD
pkg_pty_local --> pkg_sandbox_policy
pkg_pty_local --> pkg_session
pkg_pty_local --> pkg_subprocess
pkg_session_registry_live --> pkg_invariants
pkg_session_registry_live --> pkg_session
pkg_session_registry_live --> pkg_session_registry
pkg_session_registry_live --> pkg_session_title
pkg_tasks_local --> pkg_agent
pkg_tasks_local --> pkg_invariants
pkg_tasks_local --> pkg_tasks
@@ -796,13 +776,6 @@ flowchart TD
pkg_repeat_tool_guard --> pkg_agent
pkg_repeat_tool_guard --> pkg_invariants
pkg_repeat_tool_guard --> pkg_tools
pkg_source_guard --> pkg_agent
pkg_source_guard --> pkg_fs
pkg_source_guard --> pkg_invariants
pkg_source_guard --> pkg_llm
pkg_source_guard --> pkg_sandbox
pkg_source_guard --> pkg_session
pkg_source_guard --> pkg_tools
pkg_tool_lsp --> pkg_invariants
pkg_tool_lsp --> pkg_llm
pkg_tool_lsp --> pkg_lsp
@@ -962,6 +935,24 @@ flowchart TD
pkg_cli_demo --> pkg_session_persistence_jsonl
pkg_cli_demo --> pkg_tools
pkg_cli_demo --> pkg_workspace_context
pkg_tui_demo --> pkg_agent
pkg_tui_demo --> pkg_agent_loop
pkg_tui_demo --> pkg_agent_spine_demo
pkg_tui_demo --> pkg_command_goal
pkg_tui_demo --> pkg_commands
pkg_tui_demo --> pkg_invariants
pkg_tui_demo --> pkg_llm
pkg_tui_demo --> pkg_session
pkg_tui_demo --> pkg_session_checkpoint_policy
pkg_tui_demo --> pkg_session_persistence_jsonl
pkg_tui_demo --> pkg_session_query
pkg_tui_demo --> pkg_session_query_sqlite
pkg_tui_demo --> pkg_session_reference
pkg_tui_demo --> pkg_tool_ask_user
pkg_tui_demo --> pkg_tools
pkg_tui_demo --> pkg_tui
pkg_tui_demo --> pkg_user_interaction
pkg_tui_demo --> pkg_workspace_context
pkg_sdk_client --> pkg_invariants
pkg_sdk_client --> pkg_llm
pkg_sdk_client --> pkg_sdk_protocol
@@ -1054,7 +1045,6 @@ flowchart TD
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`session-registry`](../packages/session-registry/session-registry) | `session-registry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
@@ -1072,11 +1062,9 @@ flowchart TD
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) |
| [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
| [`session-registry-file`](../packages/session-registry/session-registry-file) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry) |
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -1092,7 +1080,6 @@ flowchart TD
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
| [`session-registry-live`](../packages/session-registry/session-registry-live) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry), [`session-title`](../packages/session-title/session-title) |
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
@@ -1120,7 +1107,6 @@ flowchart TD
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
| [`source-guard`](../packages/guard/source-guard) | `guard` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
| [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
@@ -1141,5 +1127,6 @@ flowchart TD
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
| [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) |
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
@@ -1,38 +0,0 @@
# Test-only composition: the model attempts one `write` into a staging-shaped
# git fixture, so the guard's denial is observed through the real Loader and app.
- id: source-guard-mock-llm
name: './mock-llm.ts'
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: fs
name: '@deepseek-ai/dsh-fs-local'
# Read-before-edit policy: without it the write would resolve `createIfAbsent`
# and the transcript would not show the guard as the sole reason for refusal.
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
# Mounts the guard with `protectedCheckout` resolved against the process cwd, so
# it arms for the staging fixture the smoke builds there rather than for the
# checkout running the test (the config default is this module's own location).
- id: source-guard-fixture
name: './mount-guard.ts'
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
provider: source-guard-mock
model: source-guard-mock
persona: 'Test the source guard.'
persistenceRoot: './.sessions'
persistenceCompression: none
workspaceContext: false
@@ -1,43 +0,0 @@
import { resolve } from 'node:path'
import type { Context } from 'cordis'
import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
/** The staged file the smoke builds in the process cwd; the guard must refuse to write it. */
const TARGET = resolve('staging/guarded.ts')
/**
* Two-step adapter for the source-guard Loader fixture: the first step calls
* `write` on the staged file, the second closes the turn once a tool result has
* come back, so the transcript records what the model received.
*/
class SourceGuardMockAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const alreadyCalled = options.messages.some(message => message.content.some(
block => block.type === 'tool-result',
))
if (alreadyCalled) {
const text = 'denied as expected'
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text }
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
yield { type: 'finish', reason: { kind: 'stop' } }
return
}
const callId = CallId('source-guard-write')
const args = JSON.stringify({ file_path: TARGET, content: 'edited\n' })
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: args }
yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: args } }
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
yield { type: 'finish', reason: { kind: 'tool-calls' } }
}
}
export const name = 'source-guard-mock-llm'
export const inject = ['llm']
/** Register the test-only `source-guard-mock` adapter. */
export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['source-guard-mock'], new SourceGuardMockAdapter())
}
@@ -1,14 +0,0 @@
import { resolve } from 'node:path'
import type { Context } from 'cordis'
import * as SourceGuard from '@deepseek-ai/dsh-source-guard'
export const name = 'source-guard-fixture'
/**
* Mount the real guard against the staging fixture in the process cwd. The
* checkout under protection is a runtime fact of the isolated smoke directory,
* which no static config value can name.
*/
export async function apply(ctx: Context): Promise<void> {
await ctx.plugin(SourceGuard, { protectedCheckout: resolve('staging/guard-anchor.ts') })
}
@@ -1,16 +0,0 @@
#!/usr/bin/env node
/** Test driver that sends two turns through one Headless Loader composition. */
import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts'
const configPath = process.argv[2]
if (configPath === undefined) throw new Error('tmux-context driver requires a config path')
const ctx = await boot('tmux-context-e2e', resolveConfigPath(configPath, undefined))
try {
await runOneShot(ctx, { task: 'first' })
await runOneShot(ctx, { task: 'second' })
} finally {
await ctx.fiber.dispose()
}
@@ -1,46 +0,0 @@
import type { Context } from 'cordis'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
/**
* Deterministic `ctx.bash` for the tmux-context Loader fixture: any command
* (the plugin's `tmux display-message`) returns a fixed tab-delimited reading,
* so the injected tmux location is stable without a real tmux server. `start()`
* throws — tmux-context must never spawn a background process.
*/
class TmuxMockBash extends BashExecutor {
override resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxPolicy: request.sandboxPolicy,
}
}
override run(_spec: BashExecSpec): Promise<BashRunResult> {
const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t')
return Promise.resolve({
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 60_000,
stdout: { text: `${line}\n`, truncated: false },
stderr: { text: '', truncated: false },
})
}
override start(): BashProcess {
throw new Error('tmux-context must never start a background task')
}
}
export const name = 'tmux-context-mock-bash'
/** Register the deterministic `ctx.bash` executor for the fixture. */
export function apply(ctx: Context): void {
ctx.plugin(TmuxMockBash)
}
@@ -1,22 +0,0 @@
import type { Context } from 'cordis'
import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
/** Deterministic one-step adapter for the tmux-context Loader fixture. */
class TmuxContextMockAdapter extends LlmAdapter {
async * stream(): AsyncIterable<StreamChunk> {
const text = 'tmux context sampled'
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text }
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
export const name = 'tmux-context-mock-llm'
export const inject = ['llm']
/** Register the test-only `tmux-context-mock` adapter. */
export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter())
}
@@ -1,21 +0,0 @@
# Test-only composition: keep tmux-context opt-in while exercising its real Loader/app path.
# A deterministic mock ctx.bash returns a fixed tmux reading, so the injected location
# is stable without a real tmux server on the test host.
- id: tmux-context-mock-llm
name: './tmux-context-mock-llm.ts'
- id: bash
name: './tmux-context-mock-bash.ts'
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
provider: tmux-context-mock
model: tmux-context-mock
persona: 'Test the tmux-context plugin.'
persistenceRoot: './.sessions'
persistenceCompression: 'none'
workspaceContext: false
-2
View File
@@ -56,7 +56,6 @@
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*",
"@deepseek-ai/dsh-skill": "workspace:*",
"@deepseek-ai/dsh-skill-local": "workspace:*",
"@deepseek-ai/dsh-source-guard": "workspace:*",
"@deepseek-ai/dsh-spill-local": "workspace:*",
"@deepseek-ai/dsh-spill-policy": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
@@ -69,7 +68,6 @@
"@deepseek-ai/dsh-tasks-local": "workspace:*",
"@deepseek-ai/dsh-time-context": "workspace:*",
"@deepseek-ai/dsh-timeout-policy": "workspace:*",
"@deepseek-ai/dsh-tmux-context": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-tool-ask-user": "workspace:*",
"@deepseek-ai/dsh-tool-bash": "workspace:*",
-25
View File
@@ -33,15 +33,10 @@
"headless-agent/tests/fixtures/semantic-checkpoint-agent.ts",
"headless-agent/tests/fixtures/subagent-inheritance-agent.ts",
"headless-agent/tests/fixtures/goal-domain/seed-goal.ts",
"headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts",
"headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts",
"headless-agent/tests/fixtures/time-context-driver.ts",
"headless-agent/tests/fixtures/time-context-mock-llm.ts",
"headless-agent/tests/fixtures/telemetry-otel-driver.ts",
"headless-agent/tests/fixtures/telemetry-redact-rule.ts",
"headless-agent/tests/fixtures/tmux-context-driver.ts",
"headless-agent/tests/fixtures/tmux-context-mock-llm.ts",
"headless-agent/tests/fixtures/tmux-context-mock-bash.ts",
"acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts",
"acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts",
"acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts",
@@ -178,16 +173,6 @@
"tests/**/*.ts"
]
},
"packages/context/tmux-context": {
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/lsp/lsp-local": {
"entry": [
"tests/**/*.spec.ts",
@@ -303,16 +288,6 @@
"tests/**/*.ts"
]
},
"packages/guard/source-guard": {
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/session-registry/session-registry-file": {
"entry": [
"tests/**/*.spec.ts",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/README.md
README.md: a5244dfe99a714605744b57d33f97359d4d6fa4e
README.zh.md: 6036d58c6937d025adc36d9b2d12f51e396ee3d0
README.md: bc3237b98732c23e6a2b120e055f7713b91f9b7c
README.zh.md: b195a4c0b96b1f6f0b66bc6efa99a4a66b3c2fa2
+1 -2
View File
@@ -2,13 +2,12 @@
English | [中文](README.zh.md)
Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` and `tmux-context` are opt-in, while the standard TUI bundle composes `session-reference` explicitly.
Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in, while the standard TUI bundle composes `session-reference` explicitly.
| Package | Role | ctx key |
|---|---|---|
| `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` |
| `time-context/` | Durable per-step current time and elapsed-time context | (none) |
| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/pre-step`, reads `ctx.bash`) |
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) |
The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.
+4 -4
View File
@@ -2,12 +2,12 @@
[English](README.md) | 中文
这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context`显式启用,标准 TUI 组合包则会显式组合 `session-reference`
这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context`要选择启用,标准 TUI 组合包则会显式组合 `session-reference`
| 包 | 职责 | ctx key |
|---|---|---|
| `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` |
| `time-context/` | 持久的逐步骤当前时间与已用时上下文 | (无) |
| `workspace-context/` | `AGENTS.md``CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute` |
| `time-context/` | 持久的逐步骤当前时间与时上下文 | (无) |
| `workspace-context/` | `AGENTS.md``CLAUDE.md` 工作区上下文 loader | (监听 `agent/session-prefix` + `tools/post-execute` |
[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了每个 agent(智能体)和会话各自隔离的方式,以及相应的生命周期拆分。
[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了它的逐 agent/会话隔离与生命周期拆分。
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md
README.md: 5ea36948d6d83135c5aa97650c0d77e942adbbaa
README.zh.md: 914d8d7c99c37de2c64541bcf4968996d819077d
-68
View File
@@ -1,68 +0,0 @@
# @deepseek-ai/dsh-tmux-context
English | [中文](README.zh.md)
Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md).
## Config
```yaml
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
config:
refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn
```
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` injects whenever the tmux state changed since the last injection. A positive value additionally suppresses injections that fall within that many milliseconds of the latest one.
## How it reads tmux
The plugin prepends an `agent/step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam:
```sh
[ -n "$TMUX_PANE" ] || exit 1
self_tty=$(ps -o tty= -p <pid> | tr -d ' ')
pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
[ "$pane_tty" = "/dev/$self_tty" ] || exit 1
exec tmux display-message -t "$TMUX_PANE" -p '<format>'
```
`$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.bash` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.bash` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error.
State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane is picked up without any tmux hook or background process. The plugin re-injects only when the rendered tmux state differs from its last injection, so an unchanged location adds nothing.
## Timing semantics
When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback).
## Model Experience
### Preparation-time tmux location
#### What the model sees
On each turn whose tmux state changed, one source-tagged context message with the three lines below. `<window-layout>` is tmux's compact pane-tree description; pane and window pixel sizes are intentionally excluded, and the contents of sibling panes are never captured.
##### Changed-turn reading
```markdown
tmux location (turn <turn>):
session <session>, window <index> "<name>", pane <index> <pane-id>
window active=<0|1>, pane active=<0|1>, layout <window-layout>
```
#### Token effect
Each two-line reading accumulates until compaction shadows it. Unchanged locations and interval suppression add nothing.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **First step only** — a pane moved or resized mid-turn is reflected on the next turn, not between steps.
- **Own location only** — the plugin never captures the visible text of sibling panes.
- **Layout, not size** — pane/window pixel dimensions are omitted; only the layout tree and active flags are reported.
- **Tab-delimited fields** — a tmux window name containing the literal two-character sequence `\t` would mis-split the reading and be skipped as malformed; ordinary names are unaffected.
- **tty-based pane detection** — the process is considered "in tmux" only when its controlling terminal matches `$TMUX_PANE`'s `#{pane_tty}`. This deliberately excludes terminals that inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor (e.g. a VS Code integrated terminal). `ps -o tty=` is POSIX; the check is a no-op wherever it or `#{pane_tty}` is unavailable.
@@ -1,68 +0,0 @@
# @deepseek-ai/dsh-tmux-context
[English](README.md) | 中文
可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。`dsh-agent-spine-demo` 与随附示例均不挂载它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。
## 配置
```yaml
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
config:
refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn
```
`refreshIntervalMs` 必须是非负安全整数。省略或 `0` 表示只要 tmux 状态自上次注入以来发生变化就注入。正值会额外抑制距最近一次注入不足该毫秒数的注入。
## 如何读取 tmux
插件前置注册一个 `agent/step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令:
```sh
[ -n "$TMUX_PANE" ] || exit 1
self_tty=$(ps -o tty= -p <pid> | tr -d ' ')
pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
[ "$pane_tty" = "/dev/$self_tty" ] || exit 1
exec tmux display-message -t "$TMUX_PANE" -p '<format>'
```
仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX``$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty,而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.bash` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.bash` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。
状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。
## 时序语义
当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入的 `user/message`,来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step;由于监听器最先运行,当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)。
## 模型体验
### 准备期 tmux 位置
#### 模型看到的内容
在 tmux 状态发生变化的每一轮,注入一条带来源标记、含以下三行的上下文消息。`<window-layout>` 是 tmux 紧凑的 pane 树描述;pane 与 window 的像素尺寸有意省略,相邻 pane 的内容从不采集。
##### 变化轮次读数
```markdown
tmux location (turn <turn>):
session <session>, window <index> "<name>", pane <index> <pane-id>
window active=<0|1>, pane active=<0|1>, layout <window-layout>
```
#### Token 影响
每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。
#### KV 缓存影响
只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV 缓存条目失效。
## 已知限制与后续工作
- **仅第一个 step**——轮次中途移动或缩放的 pane 会在下一轮反映,而非在 step 之间。
- **仅自身位置**——插件从不采集相邻 pane 的可见文本。
- **只有布局,没有尺寸**——省略 pane/window 像素尺寸;仅报告布局树与活动标志。
- **制表符分隔字段**——若 tmux window 名称包含字面两字符序列 `\t`,会使读数分割错误并作为非法读数跳过;常规名称不受影响。
- **基于 tty 的 pane 判定**——只有当进程的控制终端与 `$TMUX_PANE``#{pane_tty}` 一致时,才视为“位于 tmux 中”。这会有意排除从 tmux 祖先进程继承 `$TMUX``$TMUX_PANE` 的终端(如 VS Code 集成终端)。`ps -o tty=` 属于 POSIX;在其或 `#{pane_tty}` 不可用的环境中,该检查即为空操作。
@@ -1,49 +0,0 @@
{
"name": "@deepseek-ai/dsh-tmux-context",
"description": "Opt-in durable per-step context with this agent's tmux pane and window location",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
-227
View File
@@ -1,227 +0,0 @@
/**
* Opt-in request-preparation tmux-location context. Eligible step attempts
* append durable, source-attributed context naming the tmux session, window,
* and pane this agent process runs in, plus the window's pane-tree layout.
*
* The plugin pulls state once per turn, on the first step (`step === 1`), by
* running one `tmux display-message` through the `ctx.bash` executor seam. It
* confirms this process genuinely runs inside the pane `$TMUX_PANE` names by
* matching the pane's `#{pane_tty}` against this process's controlling terminal,
* so a terminal that merely inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor
* (e.g. a VS Code integrated terminal) reads as "not in tmux". It re-injects
* only when the rendered tmux state changes since the last injection (a moved,
* renamed, or re-laid-out pane), with an optional `refreshIntervalMs` floor
* between injections. Absent tmux environment, an inherited-only environment,
* absent `ctx.bash`, or a failed query is a no-op, never an error.
*
* @module @deepseek-ai/dsh-tmux-context
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { BashExecutor } from '@deepseek-ai/dsh-bash'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tmux-context'
/** The agent registry that owns the `agent/step` lifecycle seam. */
export const inject = ['agents']
/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */
export interface Config {
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */
refreshIntervalMs?: number
}
/** Schemastery validation for {@link Config}. */
export const Config: z<Config> = z.object({
refreshIntervalMs: z.number(),
})
/**
* Tab-separated tmux format fields, in query order. Layout (`window_layout`)
* is the pane-tree description; pane/window pixel sizes are intentionally
* excluded (own location and layout only, per the package scope).
*/
const TMUX_FIELDS = [
'#{session_name}',
'#{window_index}',
'#{window_name}',
'#{pane_index}',
'#{pane_id}',
'#{window_active}',
'#{pane_active}',
'#{window_layout}',
] as const
/** Structured tmux location parsed from one `display-message` reading. */
interface TmuxLocation {
sessionName: string
windowIndex: string
windowName: string
paneIndex: string
paneId: string
windowActive: string
paneActive: string
windowLayout: string
}
/** Prefix marking the volatile turn/step preamble line of a rendered reading. */
const READING_PREFIX = 'tmux location (turn '
/**
* Field separator between tmux format fields. tmux does not interpret C escapes
* in a format, so the literal two-character sequence `\t` is emitted verbatim
* and split back out here; this avoids embedding raw whitespace in the command.
*/
const FIELD_SEP = '\\t'
/**
* Read this process's tmux location through the bash seam, or `undefined` when
* this process is not genuinely running inside a tmux pane or the query fails.
*
* `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell
* (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and
* `$TMUX_PANE` from that ancestor, so the variables are present even though this
* process does not live in that pane. The command therefore also compares the
* pane's `#{pane_tty}` against this process's own controlling terminal
* (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty,
* an inherited environment names some other pane's tty. Fields are emitted only
* on a match, so an inherited environment reads as "not in tmux" and injects
* nothing.
*
* @param bash - the executor seam used to run the read-only tmux/ps commands.
* @param processId - this agent process's pid, whose controlling tty must match the pane.
* @param signal - abort signal forwarded to the executor.
* @returns the parsed location, or `undefined` when not in a real pane or on any failure.
*/
async function queryTmuxLocation(
bash: BashExecutor,
processId: number,
signal: AbortSignal,
): Promise<TmuxLocation | undefined> {
const format = TMUX_FIELDS.join(FIELD_SEP)
const command = [
'[ -n "$TMUX_PANE" ] || exit 1',
`self_tty=$(ps -o tty= -p ${processId} | tr -d ' ')`,
'[ -n "$self_tty" ] || exit 1',
'pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1',
'[ "$pane_tty" = "/dev/$self_tty" ] || exit 1',
`exec tmux display-message -t "$TMUX_PANE" -p '${format}'`,
].join('\n')
const spec = bash.resolve({ command, signal })
const result = await bash.run(spec)
if (result.exitCode !== 0) return undefined
const line = result.stdout.text.split('\n', 1)[0] as string
const parts = line.split(FIELD_SEP)
if (parts.length !== TMUX_FIELDS.length) return undefined
const [
sessionName,
windowIndex,
windowName,
paneIndex,
paneId,
windowActive,
paneActive,
windowLayout,
] = parts as [string, string, string, string, string, string, string, string]
if (paneId.length === 0) return undefined
return {
sessionName,
windowIndex,
windowName,
paneIndex,
paneId,
windowActive,
paneActive,
windowLayout,
}
}
/**
* Render the stable tmux state block: the part of a reading compared for
* change suppression. It excludes the turn preamble so re-injection is driven
* only by tmux state, not by loop position.
*/
function renderState(location: TmuxLocation): string {
return `session ${location.sessionName}, `
+ `window ${location.windowIndex} ${JSON.stringify(location.windowName)}, `
+ `pane ${location.paneIndex} ${location.paneId}\n`
+ `window active=${location.windowActive}, pane active=${location.paneActive}, `
+ `layout ${location.windowLayout}`
}
/** Render the full durable reading, including the volatile turn preamble. */
function renderReading(location: TmuxLocation, turn: number): string {
return `${READING_PREFIX}${turn}):\n${renderState(location)}`
}
/**
* The stable state block of this plugin's latest durable injection, or
* `undefined` when the session has none. Scans raw durable events so the
* schedule survives compaction and resumed processes without process-local
* cache state.
*/
function latestInjectedState(agent: Agent): { state: string; time: number } | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
const [block] = event.data.content
if (block?.type !== 'text') return undefined
const newline = block.text.indexOf('\n')
const state = newline === -1 ? '' : block.text.slice(newline + 1)
return { state, time: event.time }
}
}
return undefined
}
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
if (refreshIntervalMs !== undefined && (
!Number.isSafeInteger(refreshIntervalMs)
|| refreshIntervalMs < 0
)) {
throw new TypeError(
`tmux-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
)
}
}
/**
* Register a prepended `agent/step` listener for the lifetime of `ctx`.
* @param ctx - plugin context; the listener is disposed with it.
* @param config - durable refresh scheduling configuration.
* @throws when the refresh interval is invalid.
*/
export function apply(ctx: Context, config: Config): void {
const refreshIntervalMs = config.refreshIntervalMs
validateRefreshInterval(refreshIntervalMs)
ctx.on('agent/step', async (
agent: Agent,
turn: number,
step: number,
signal: AbortSignal,
): Promise<void> => {
if (signal.aborted || step !== 1) return
const bash = ctx.get('bash')
if (bash === undefined) return
const previous = latestInjectedState(agent)
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) {
const now = Date.now()
if (now >= previous.time && now - previous.time < refreshIntervalMs) return
}
const location = await queryTmuxLocation(bash, process.pid, signal)
if (location === undefined) return
const state = renderState(location)
if (previous !== undefined && previous.state === state) return
agent.inject(createUserMessage({
content: [{ type: 'text', text: renderReading(location, turn) }],
source: { kind: 'plugin', plugin: name },
}))
}, { prepend: true })
}
@@ -1,30 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tmux-context`.
* @module @deepseek-ai/dsh-tmux-context/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tmux-context'
/** Cordis companion plugin name. */
export const name = 'tmux-context-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a reading is a per-turn snapshot of external tmux state, so the session
* holds no cross-event relation to check; scheduling and format are owned by pipeline tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -1,77 +0,0 @@
import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { type SessionEvent } from '@deepseek-ai/dsh-session'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
// Keep the Loader config under examples so both modes exercise the same deployable
// topology: local fixture source plus bare plugins owned by the examples workspace.
const driver = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/tmux-context-driver.ts',
import.meta.url,
))
const configPath = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/tmux-context.cordis.yml',
import.meta.url,
))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
async function jsonlFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const paths = await Promise.all(entries.map(async (entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return jsonlFiles(path)
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
}))
return paths.flat()
}
describe('tmux-context through a real headless cordis.yml', () => {
it('injects one ordered tmux-location event on the first turn and suppresses the unchanged second', async () => {
let events: SessionEvent[] = []
const { stderr } = await runLoaderSmoke({
label: 'tmux-context headless smoke',
tempDirPrefix: 'tmux-context-e2e-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
inspect: async (cwd) => {
const logs = await jsonlFiles(join(cwd, '.sessions'))
expect(logs).toHaveLength(1)
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
},
})
expect(stderr).not.toContain('UNHANDLED')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
const contexts = events.filter(
(event): event is SessionEvent<'user/message'> =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tmux-context')
// Two identical-state turns: the location injects once and is suppressed after.
expect(contexts).toHaveLength(1)
const [reading] = contexts
if (reading === undefined) throw new Error('missing tmux-context reading')
const starts = events.filter(event => event.type === 'step/start')
expect(reading.seq).toBeLessThan(starts[0]!.seq)
expect(reading.surfaceOp).toBe('append')
const text = reading.data.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
expect(text).toBe(
'tmux location (turn 1):\n'
+ 'session work, window 0 "editor", pane 1 %3\n'
+ 'window active=1, pane active=1, layout a1b2,80x24,0,0,4',
)
const headers = events.filter(event => event.type === 'request/header')
expect(JSON.stringify(headers)).not.toContain('tmux location (turn')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
@@ -1,367 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import * as tmuxContext from '@deepseek-ai/dsh-tmux-context'
import type { Config } from '@deepseek-ai/dsh-tmux-context'
const SIGNAL = new AbortController().signal
/** One `#{...}`-joined tmux reading line for the eight queried fields. */
function tmuxLine(fields: {
sessionName?: string
windowIndex?: string
windowName?: string
paneIndex?: string
paneId?: string
windowActive?: string
paneActive?: string
windowLayout?: string
} = {}): string {
return [
fields.sessionName ?? '0',
fields.windowIndex ?? '1',
fields.windowName ?? 'node',
fields.paneIndex ?? '2',
fields.paneId ?? '%90',
fields.windowActive ?? '1',
fields.paneActive ?? '0',
fields.windowLayout ?? 'd517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}',
].join('\\t')
}
function runResult(stdout: string, overrides: Partial<BashRunResult> = {}): BashRunResult {
return {
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 60_000,
stdout: { text: stdout, truncated: false },
stderr: { text: '', truncated: false },
...overrides,
}
}
/** A scriptable fake `ctx.bash` recording the command it was asked to run. */
class FakeBash extends BashExecutor {
commands: string[] = []
result: BashRunResult = runResult(`${tmuxLine()}\n`)
runError?: Error
override resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? '/work',
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxPolicy: request.sandboxPolicy,
}
}
override async run(spec: BashExecSpec): Promise<BashRunResult> {
this.commands.push(spec.command)
if (this.runError) throw this.runError
return this.result
}
override start(): BashProcess {
throw new Error('tmux-context must never start a background task')
}
}
async function mount(config: Config, withBash: true): Promise<{ ctx: Context; bash: FakeBash }>
async function mount(config?: Config, withBash?: boolean): Promise<{ ctx: Context; bash: FakeBash | undefined }>
async function mount(
config: Config = {},
withBash = false,
): Promise<{ ctx: Context; bash: FakeBash | undefined }> {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
let bash: FakeBash | undefined
if (withBash) {
await ctx.plugin(FakeBash)
bash = ctx.bash as FakeBash
}
await ctx.plugin(tmuxContext, config)
return { ctx, bash }
}
function sessionAgent(session: Session, id = 'agent'): Agent {
return {
id: SessionId(id),
options: {},
session,
status: 'running',
acceptsNextStep: true,
ctx: new Context(),
followup: () => {},
steer: () => {},
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
send: () => {},
cancel() {},
whenIdle: () => Promise.resolve(),
}
}
function openMessageTurn(session: Session, turn: number): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `turn ${turn}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
function contextTexts(session: Session): string[] {
const texts: string[] = []
for (const event of session.events) {
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tmux-context') {
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
}
}
return texts
}
async function fire(
ctx: Context,
agent: Agent,
turn: number,
step: number,
signal: AbortSignal = SIGNAL,
): Promise<void> {
await agentEvents(ctx, agent).serial('agent/step', turn, step, signal)
}
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('tmux-context injection', () => {
it('injects the tmux location on the first step of a turn', async () => {
const { ctx } = await mount({}, true)
const session = new Session(SessionId('first'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toEqual([
'tmux location (turn 1):\n'
+ 'session 0, window 1 "node", pane 2 %90\n'
+ 'window active=1, pane active=0, '
+ 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}',
])
const event = session.events.at(-1)
if (event?.type !== 'user/message') throw new Error('missing tmux context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' })
expect(event.surfaceOp).toBe('append')
})
it('queries the pane this process runs in and matches its controlling tty', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('command'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(bash.commands).toHaveLength(1)
const command = bash.commands[0]!
expect(command).toContain('[ -n "$TMUX_PANE" ]')
expect(command).toContain('tmux display-message -t "$TMUX_PANE" -p')
// Guards against an inherited $TMUX_PANE: the pane's tty must equal this
// process's controlling tty (resolved for this exact pid).
expect(command).toContain(`ps -o tty= -p ${process.pid}`)
// The exact fragment matters: unquoted, `#` starts a shell comment and the
// substitution silently breaks while a substring check still passes.
expect(command).toContain('pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1')
expect(command).toContain('[ "$pane_tty" = "/dev/$self_tty" ]')
})
it('does not run on later steps of a turn', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('later-step'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 2)
expect(bash.commands).toHaveLength(0)
expect(contextTexts(session)).toHaveLength(0)
})
it('re-injects a new turn only when tmux state changed', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('change'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
await fire(ctx, agent, 1, 1)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Same state on turn 2: suppressed.
openMessageTurn(session, 2)
await fire(ctx, agent, 2, 1)
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
expect(contextTexts(session)).toHaveLength(1)
// Moved pane on turn 3: re-injected.
bash.result = runResult(`${tmuxLine({ windowName: 'shell', paneId: '%12' })}\n`)
openMessageTurn(session, 3)
await fire(ctx, agent, 3, 1)
const texts = contextTexts(session)
expect(texts).toHaveLength(2)
expect(texts[1]).toContain('tmux location (turn 3):')
expect(texts[1]).toContain('window 1 "shell", pane 2 %12')
})
it('honors a positive refresh interval between injections', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true)
const session = new Session(SessionId('interval'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
await fire(ctx, agent, 1, 1)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Changed state but inside the interval: suppressed, and never queried.
bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`)
vi.setSystemTime(5_000)
openMessageTurn(session, 2)
await fire(ctx, agent, 2, 1)
expect(contextTexts(session)).toHaveLength(1)
expect(bash.commands).toHaveLength(1)
// Past the interval: queried and re-injected.
vi.setSystemTime(12_000)
openMessageTurn(session, 3)
await fire(ctx, agent, 3, 1)
expect(contextTexts(session)).toHaveLength(2)
expect(bash.commands).toHaveLength(2)
})
})
describe('tmux-context prior-reading resilience', () => {
it('treats a prior non-text plugin reading as absent and injects afresh', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('prior-non-text'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
session.append('user/message', createUserMessage({
content: [{ type: 'reasoning', text: 'not a location' }],
source: { kind: 'plugin', plugin: 'tmux-context' },
}), { surfaceOp: 'append' })
await fire(ctx, agent, 1, 1)
expect(bash.commands).toHaveLength(1)
expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):')
})
it('treats a prior single-line plugin reading (no newline) as empty state', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('prior-single-line'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'single line, no newline' }],
source: { kind: 'plugin', plugin: 'tmux-context' },
}), { surfaceOp: 'append' })
await fire(ctx, agent, 1, 1)
// Empty prior state never equals the multi-line reading, so it re-injects.
expect(bash.commands).toHaveLength(1)
expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):')
})
})
describe('tmux-context no-op paths', () => {
it('is a no-op when no bash executor is mounted', async () => {
const { ctx } = await mount()
const session = new Session(SessionId('no-bash'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toHaveLength(0)
})
it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => {
const { ctx, bash } = await mount({}, true)
bash.result = runResult('', { exitCode: 1 })
const session = new Session(SessionId('outside-tmux'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toHaveLength(0)
})
it('is a no-op when the reading has the wrong field count', async () => {
const { ctx, bash } = await mount({}, true)
bash.result = runResult('0\\t1\\tnode\n')
const session = new Session(SessionId('malformed'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toHaveLength(0)
})
it('is a no-op when the pane id is empty', async () => {
const { ctx, bash } = await mount({}, true)
bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`)
const session = new Session(SessionId('empty-pane'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toHaveLength(0)
})
it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => {
const { ctx } = await mount({}, true)
const session = new Session(SessionId('ordering'))
const agent = sessionAgent(session)
openMessageTurn(session, 1)
let ordinarySawContext = false
ctx.on('agent/step', (subject) => {
ordinarySawContext = subject.session.events.some(
event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tmux-context',
)
})
const abort = new AbortController()
abort.abort()
await fire(ctx, agent, 1, 1, abort.signal)
expect(contextTexts(session)).toHaveLength(0)
await fire(ctx, agent, 1, 1)
expect(ordinarySawContext).toBe(true)
expect(contextTexts(session)).toHaveLength(1)
})
})
describe('tmux-context configuration', () => {
it('rejects a negative refresh interval at plugin load', async () => {
await expect(mount({ refreshIntervalMs: -1 })).rejects.toThrow(
/refreshIntervalMs must be a non-negative safe integer/,
)
})
it('rejects a non-integer refresh interval at plugin load', async () => {
await expect(mount({ refreshIntervalMs: 1.5 })).rejects.toThrow(
/refreshIntervalMs must be a non-negative safe integer/,
)
})
})
@@ -1,40 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../bash/bash"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../support/loader-smoke"
},
{
"path": "../../support/invariants"
},
{
"path": "../../core/session"
}
]
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/guard/README.md
README.md: b7375fd2bb12ae0cec94b13e6a1012c6f143bdad
README.zh.md: bba5c144d0663266e4327e388b9915cde176ce08
README.md: 59ab2fcea91bbbb9f6628523f3c6f13497d6f742
README.zh.md: caec5618f9ddc27825ad68cd4145f6197c7b0517
-1
View File
@@ -7,6 +7,5 @@ Behavioral guard plugins that watch the agent loop and correct it — some by nu
| Package | Role | ctx key |
|---|---|---|
| `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) |
| `source-guard/` | Denies file edits inside a dsh staging worktree until the required skill is loaded | (listens on `ctx.tools`' waterfalls) |
An advisory guard's reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything such a guard says to the model is reconstructable from the session log. An enforcing guard instead decides on `tools/pre-execute`, where a `deny` becomes the call's error result and the operation never dispatches.
+3 -3
View File
@@ -2,10 +2,10 @@
[English](README.md) | 中文
这组行为 guard 插件会监视 agent loop(智能体循环)中的低效模式,并提醒模型调整方向。这里只有一个**产品**包(package,不设接口/实现 seam:guard 是现有核心 seam`tools/post-execute``agent/prompt-submit``agent/status`)的自包含消费方,并非可替换能力。
这组行为 guard 插件会监视 agent(智能体循环并加以纠正:一部分提醒模型调整方向,另一部分则直接拒绝某个操作。它们都是**产品**包,不设接口/实现 seam:guard 是现有核心 seam`tools/pre-execute``tools/post-execute``agent/prompt-submit``agent/status`)的自包含消费方,并非可替换能力。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall,即瀑布式事件) |
| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall瀑布式事件) |
提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递;agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools))。因此,guard 告诉模型的所有内容都能从会话日志中重建。
建议型 guard 的提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递;agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools))。因此,此类 guard 告诉模型的所有内容都能从会话日志中重建。强制型 guard 则在 `tools/pre-execute` 上做出决策,其 `deny` 会成为该调用的错误结果,操作绝不会分派执行。
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/guard/source-guard/README.md
README.md: a7406d71c6591f78d12b6c02ec22bc4b0b3d517f
README.zh.md: 916d0513b77e17989730fdf27ef50d21013f4e58
-88
View File
@@ -1,88 +0,0 @@
# @deepseek-ai/dsh-source-guard
English | [中文](README.zh.md)
An enforcement gate, not a model-facing tool: it never appears in the tool list and adds exactly one behavior — it denies a `write` or `edit` whose target sits inside the dsh checkout the running harness was launched from, on that checkout's own branch, until the calling session's durable log shows a successful load of the `dsh-customize` skill. That skill requires personal changes to be implemented in a task worktree and integrated under the staging lock; this plugin turns its central rule ("do not edit the personal staging checkout directly") from prompt guidance into a boundary the model cannot cross by forgetting.
## Config
```yaml
- id: source-guard
name: '@deepseek-ai/dsh-source-guard'
config:
requiredSkill: dsh-customize # default; the skill whose load lifts the denial
tools: [write, edit] # default; the gated tool names
protectedCheckout: /path/to/checkout # defaults to this module's own location
```
Every field fails loud at plugin load: an empty `tools` list, a blank `requiredSkill`, or a relative `protectedCheckout` throws, never a silent fall-back.
`protectedCheckout` names a path inside the checkout to guard, and its worktree supplies BOTH protected identities: the repository and the exact branch. Its default is this module's own file, which resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. Nothing about the branch is configured or pattern-matched, so a maintainer whose staging branch follows no naming convention is protected identically. A harness running from an installed copy resolves a different repository, or none, and therefore guards nothing; the rule is meaningless outside a source checkout.
The shipped TUI composition (`apps/cli/base.cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change.
## Which paths are protected
Protection is decided by git identity read from files — `.git`, its `gitdir:` pointer, and `HEAD` — never by path prefix and never by running `git`. Prefix matching would be wrong here: the task worktrees the skill prescribes live *inside* the staging tree, at `<staging>/.worktrees/...`, and are exactly where edits belong.
Resolution walks OUTWARD from the target and stops at the first enclosing worktree, so it reports the INNERMOST one. Denial needs that worktree to match the launcher's on BOTH identities: the same shared git directory and the same branch. A task worktree nested under the protected tree answers with its own task branch and passes; the launcher's own tree answers with the launcher's branch and is denied. Repository identity is compared on symlink-resolved paths, so two routes to one repository — a session cwd under `/var/...` and a configured path under `/private/var/...` on macOS — match rather than falling open.
Requiring the exact branch, not a name pattern, keeps the gate on the live deployment only. A stale sibling checkout left by an earlier install shares the repository but runs no launcher, so the workflow rule does not apply to it and it stays editable.
A `gitdir:` pointer may be absolute (what `git worktree add` writes) or relative, which git resolves against the worktree directory holding it; both resolve here. A relative `file_path` resolves against the calling session's workspace, exactly as the filesystem tools resolve it, so it is not an unguarded route to a protected file.
The gate is deliberately narrow:
- **`read` is never gated.** Inspecting the staging checkout violates nothing, so only mutating tools are candidates.
- **`bash` is not gated.** Reliably classifying mutating shell commands is out of scope, so a determined model can still change staging through a shell.
- **Calls without an agent are allowed.** A direct `ctx.tools.execute()` caller has no session to replay and no model to correct.
- **Unresolvable git state fails OPEN.** A path outside any worktree, a detached HEAD on either side, a different repository or branch, a malformed `.git` pointer, or unreadable metadata all leave the call to the rest of the chain. A gate that blocked every write whenever git identity was unavailable would cause more harm than the violation it prevents.
- **An unresolvable target is not judged.** An empty `file_path`, a non-string one, or a relative one in a session that names no workspace leaves the call to the tool's own validation.
Worktree identity is cached per target directory for the plugin's lifetime, so repeated writes in one directory read git metadata once; a mid-session branch switch is therefore not observed.
## How the denial lifts
Satisfaction is replayed from the session's durable log: a `tool/call` naming the `skill` tool whose arguments parse to `{name: <requiredSkill>}`, paired by call id with a non-error `tool/result`. Because the log is the only state, satisfaction survives a session resume — a resumed session that already loaded the skill is not asked again. A failed load, a differently-named skill, and malformed argument JSON all leave the denial in place.
Satisfaction is per session, so a subagent with its own session must load the skill itself.
## Enforcement point
The gate is a `tools/pre-execute` listener returning `{kind: 'deny', reason}`, so the call never dispatches and the file is never touched. It delegates via `next()` in every non-violating case. Denial — not an advisory reminder — is the point: an advisory nudge leaves the violation committed, and `ask` degrades to denial in a composition without approval support.
## Testing
Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to the same repository, and unreadable metadata — to per-file 100%. The assembled-run evidence is the Loader-composition smoke (`tests/loader-composition.e2e.ts`): it boots a real headless app over `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`, seeds a staging worktree in a temporary cwd, and asserts the tool result is an error carrying the exact denial while the targeted file keeps its original bytes.
## Model Experience
### Denied filesystem call
#### What the model sees
A gated call into a protected worktree without the required skill loaded returns an error result carrying exactly the text below. No prompt section, tool schema, or successful-call text is added, and an allowed call is indistinguishable from one made without this plugin.
##### Denial result
```markdown
Error: Editing "<path>" directly is not allowed: it is inside the dsh checkout this session is running from, on branch <branch>. Load the <requiredSkill> skill first and follow it — implement in a task worktree, then integrate under the staging lock.
```
#### Token effect
Zero tokens while no denial occurs. A denial adds its small retained error result and avoids the success payload the call would have produced.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **`bash` is ungated** — the guard is a boundary for the filesystem tools only; a shell command can still mutate a protected worktree.
- **Worktree identity is cached per directory for the plugin's lifetime** — switching a protected worktree's branch mid-session does not change decisions until the next load, on either the target or the launcher side.
- **Only the launcher's own checkout is protected** — a stale sibling checkout of the same repository stays editable, deliberately; run `dsh` from it to protect it.
- **Disarmed outside a source checkout** — a harness running from an installed copy protects nothing unless `protectedCheckout` names a real checkout explicitly.
- **Satisfaction is per session** — a subagent's session must load the skill itself; a parent's load does not carry over.
- **Fail-open on unresolvable git state** — a broken or unreadable `.git` means no protection, chosen deliberately over blocking every edit.
- **One skill lifts the whole gate for the session** — loading it does not verify the workflow was actually followed, only that the instructions were read.
-88
View File
@@ -1,88 +0,0 @@
# @deepseek-ai/dsh-source-guard
[English](README.md) | 中文
这是一道强制执行门禁,而非面向模型的工具:它不会出现在工具列表中,只增加一种行为。若 `write``edit` 的目标位于运行中 harness 启动来源的 dsh 检出目录内,并处于该检出目录自身的分支上,它会拒绝调用,直到调用方会话的持久日志表明已成功加载 `dsh-customize` skill(技能)。该 skill 要求在任务 worktree 中实现个人变更,并在 staging 锁保护下完成集成;本插件把其核心规则(「不要直接编辑个人 staging 检出目录」)从提示词指导变成一道模型无法因遗忘而越过的边界。
## 配置
```yaml
- id: source-guard
name: '@deepseek-ai/dsh-source-guard'
config:
requiredSkill: dsh-customize # default; the skill whose load lifts the denial
tools: [write, edit] # default; the gated tool names
protectedCheckout: /path/to/checkout # defaults to this module's own location
```
插件加载时,每个字段都会对错误配置快速失败:`tools` 为空列表、`requiredSkill` 为空白字符串,或 `protectedCheckout` 使用相对路径时,都会抛出错误,绝不静默回退。
`protectedCheckout` 指定位于待保护检出目录内的一条路径;其 worktree 会提供两项受保护身份:仓库和确切分支。其默认值是本模块自己的文件,由此解析出运行中 harness 启动来源的检出目录——当前运行的部署,无论其分支采用什么名称。分支既无需配置,也不会通过模式匹配,因此 staging 分支不遵循任何命名约定的维护者同样会受到保护。若 harness 从已安装副本运行,则会解析到另一个仓库,或根本解析不到仓库,因此不会保护任何内容;这条规则在源码检出目录之外没有意义。
已交付的 TUI 组合(`apps/cli/base.cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。
## 受保护的路径
保护范围根据从文件读取的 Git 身份确定,即 `.git`、其中的 `gitdir:` 指针和 `HEAD`;既不按路径前缀判断,也不运行 `git`。此处若匹配路径前缀就会出错:skill 要求使用的任务 worktree 位于 staging 树*内部*的 `<staging>/.worktrees/...`,而这正是应该进行编辑的位置。
解析过程从目标路径开始向外逐层查找,遇到第一个所属 worktree 就停止,因此返回最内层的 worktree。只有该 worktree 在两项身份上都与启动器的 worktree 匹配,才会拒绝:共用同一个共享 Git 目录,且分支相同。嵌套在受保护树下的任务 worktree 会返回自己的任务分支并获准;启动器自身所在的树会返回启动器的分支并被拒绝。仓库身份会按解析符号链接后的路径进行比较,因此指向同一仓库的两条路径——macOS 上位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径——会相互匹配,而不会触发故障放行(fail-open)。
要求匹配确切分支而非名称模式,可确保门禁仅作用于当前运行的部署。先前安装留下的陈旧同级检出目录虽然共享仓库,却没有运行启动器,因此该工作流规则不适用于它,它仍可编辑。
`gitdir:` 指针既可以是绝对路径(`git worktree add` 写入的形式),也可以是相对路径;Git 会以包含该指针的 worktree 目录为基准解析相对路径,本插件对两者都能解析。相对 `file_path` 会像文件系统工具一样,相对于调用会话的工作区解析,因此不会成为绕过门禁访问受保护文件的路径。
门禁刻意保持较窄的范围:
- **`read` 从不受门禁限制。** 检查 staging 检出不构成违规,因此只有修改类工具是候选项。
- **`bash` 不受门禁限制。** 可靠识别会修改内容的 shell 命令不在范围内,因此执意修改的模型仍可通过 shell 修改 staging。
- **没有 agent(智能体)的调用会被放行。** 直接调用 `ctx.tools.execute()` 的调用方没有可供回放的会话,也没有需要纠正的模型。
- **无法解析 Git 状态时故障放行。** 不属于任何 worktree 的路径、任一侧的 HEAD 分离状态、其他仓库或分支、格式错误的 `.git` 指针或不可读的元数据,都会把调用交给链中后续环节处理。若每逢 Git 身份不可用就阻止所有写入,这道门禁造成的危害将大于它所防止的违规。
- **无法解析的目标不会被判断。** `file_path` 为空、不是字符串,或它是相对路径而会话未指定工作区时,调用会交给工具自身校验。
插件会在其整个生命周期内按目标目录缓存 worktree 身份,因此同一目录中的重复写入只读取一次 Git 元数据;由此,系统不会观察到会话中途的分支切换。
## 如何解除拒绝
是否满足解锁条件由会话的持久日志回放得出:日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: <requiredSkill>}`,并且有一条调用 id 相同的非错误 `tool/result` 与之配对。由于日志是唯一状态源,恢复会话时仍能保留这一结果:若恢复的会话已经加载该 skill,系统不会再次要求加载。加载失败、skill 名称不同或参数 JSON 格式错误,都会让拒绝继续生效。
解锁条件按会话独立满足,因此拥有独立会话的 subagent 必须自行加载该 skill。
## 强制执行点
门禁是一个 `tools/pre-execute` 监听器,返回 `{kind: 'deny', reason}`,因此调用绝不会分派执行,文件也绝不会被修改。在所有不违规的情况下,它都会通过 `next()` 委派。这里刻意采用拒绝而非建议性提醒:建议性提醒仍会让违规落地,而在不支持批准的组合中,`ask` 会退化为拒绝。
## 测试
单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径以及不可读元数据,达到逐文件 100% 覆盖率。组装运行层面的证据来自 Loader 组合冒烟测试(`tests/loader-composition.e2e.ts`):它通过 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` 启动一个真实的 headless 应用,在临时 cwd 中植入 staging worktree,并断言工具结果是携带精确拒绝文本的错误,同时目标文件保持原始字节不变。
## 模型体验
### 被拒绝的文件系统调用
#### 模型看到的内容
如果未加载必需 skill 就对受保护 worktree 发起受门禁限制的调用,系统会返回错误结果,其中的文本与下文完全一致。系统不会添加提示词段、工具 schema 或成功调用文本;允许的调用与未启用此插件时的调用完全无法区分。
##### 拒绝结果
```markdown
Error: Editing "<path>" directly is not allowed: it is inside the dsh checkout this session is running from, on branch <branch>. Load the <requiredSkill> skill first and follow it — implement in a task worktree, then integrate under the staging lock.
```
#### Token 影响
未发生拒绝时为零 token。一次拒绝会添加一条会保留在历史中的短小错误结果,同时避免生成该调用原本会产生的成功载荷。
#### KV Cache 影响
仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与暂缓工作
- **`bash` 不受门禁限制**:此插件只为文件系统工具提供边界;shell 命令仍可修改受保护的 worktree。
- **插件生命周期内按目录缓存 worktree 身份**:在会话中途切换受保护 worktree 的分支,不会改变判断结果,直至下次加载插件;目标侧和启动器侧都是如此。
- **仅保护启动器自身的检出目录**:同一仓库中的陈旧同级检出目录会被刻意保留为可编辑状态;若要保护它,请从中运行 `dsh`
- **源码检出之外不启用**:从已安装副本运行的 harness 不保护任何内容,除非 `protectedCheckout` 明确指定真实检出目录。
- **解锁条件按会话独立满足**:subagent 的会话必须自行加载该 skill;父会话的加载状态不会继承。
- **无法解析 Git 状态时故障放行**:损坏或不可读的 `.git` 会使保护失效;这是刻意选择的结果,因为另一方案是阻止所有编辑。
- **仅加载一个 skill 即可为会话解除整道门禁**:加载该 skill 并不能验证是否实际遵循工作流,只能证明已阅读这些指令。
-56
View File
@@ -1,56 +0,0 @@
{
"name": "@deepseek-ai/dsh-source-guard",
"description": "Source-guard plugin: denies direct file edits inside a dsh staging worktree until the required customization skill is loaded",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
-319
View File
@@ -1,319 +0,0 @@
/**
* Denies model-driven file mutation inside a dsh staging worktree until the
* calling session has loaded the required customization skill. Config, git
* resolution, and satisfaction semantics live in the package README; rationale
* lives in the source-guard Agent Note.
* @module @deepseek-ai/dsh-source-guard
*/
import { dirname, isAbsolute, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import z from 'schemastery'
import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-fs'
import type { CallId } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
export const name = 'source-guard'
/** The `ctx.fs` provider supplies the git-metadata reads this guard resolves paths with. */
export const inject = ['fs']
/**
* Plugin config, validated by the same-named schemastery schema plus the
* load-time checks in `apply` (misconfiguration fails loud: an empty `tools`
* list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at
* plugin load, never a silent fall-back).
*/
export interface Config {
/** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */
requiredSkill?: string
/** Tool names to gate (default `['write', 'edit']`). */
tools?: string[]
/**
* Absolute path inside the checkout this guard protects. Its worktree
* supplies BOTH protected identities: the repository (targets in any other
* repository are ignored) and the exact branch (only that branch's worktree
* is protected). Defaults to this module's own location, which resolves the
* checkout the running harness was launched from — the live deployment,
* whatever its branch is named. Set it explicitly to guard a different
* checkout, or when the harness runs from an installed copy whose own
* location is not a checkout at all.
*/
protectedCheckout?: string
}
export const Config: z<Config> = z.object({
requiredSkill: z.string().default('dsh-customize'),
tools: z.array(z.string()).default(['write', 'edit']),
protectedCheckout: z.string().default(fileURLToPath(import.meta.url)),
})
/**
* The tool whose successful call satisfies the guard. Fixed, not configurable:
* this is the harness's own skill-loading tool name, so a deployment that
* renamed it has no skill to load and nothing for this guard to observe.
*/
const SKILL_TOOL = 'skill'
/**
* The argument key every gated tool names its target with. `write` and `edit`
* share it (`dsh-tool-fs`), and gating a tool that does not is a
* misconfiguration the guard reports rather than silently allowing.
*/
const PATH_ARGUMENT = 'file_path'
/**
* The absolute `file_path` a gated call targets, or `undefined` when the
* arguments carry no usable one. Arguments arrive as the loop's parsed model
* JSON, so this is a model-input boundary: any shape is possible.
*
* A relative path resolves against the calling session's workspace, exactly as
* the filesystem tools resolve it (`dsh-tool-fs`'s `sessionCwd`). Judging only
* absolute paths would leave `write` with a relative `file_path` as an
* unguarded path to the same file.
*/
function targetPath(argumentsValue: unknown, sessionCwd: string | undefined): string | undefined {
if (typeof argumentsValue !== 'object' || argumentsValue === null) return undefined
const value = (argumentsValue as Record<string, unknown>)[PATH_ARGUMENT]
if (typeof value !== 'string' || value.length === 0) return undefined
if (isAbsolute(value)) return resolve(value)
// Without a session cwd the tools fall back to a provider-owned default this
// guard cannot observe, so the target is genuinely unresolvable here.
return sessionCwd === undefined ? undefined : resolve(sessionCwd, value)
}
/** One resolved worktree's identity: the branch its HEAD names, and the repository it belongs to. */
interface Worktree {
/** Branch name from `HEAD`, or `undefined` for a detached HEAD. */
branch: string | undefined
/**
* Symlink-resolved absolute path of the shared git directory, identifying the
* repository across worktrees. Canonical because two paths reaching one
* repository by different symlink routes must compare equal — on macOS a
* session cwd under `/var/...` and a configured path under `/private/var/...`
* name the same directory, and a lexical comparison would fail open.
*/
commonDir: string
}
/**
* What one git-metadata path holds: a file's text, the fact that it is a
* directory, or nothing resolvable. Every caller treats the unresolvable case
* as "not a worktree" and lets the call proceed, so distinguishing absence
* from a permission error would change no decision.
*/
type GitEntry =
| { kind: 'file'; text: string }
| { kind: 'directory' }
| { kind: 'absent' }
/** Probe one git-metadata path, reading its text when it is a regular file. */
async function readGitEntry(ctx: Context, path: string): Promise<GitEntry> {
try {
const target = await ctx.fs.resolve(path)
const info = await ctx.fs.stat(target)
if (info?.type === 'directory') return { kind: 'directory' }
if (info?.type !== 'file') return { kind: 'absent' }
return { kind: 'file', text: await ctx.fs.readText(target) }
} catch {
// Any resolve/stat/read failure (absent, denied, unreadable encoding)
// yields no git identity. Nothing else can reach here: the guard performs
// no other IO.
return { kind: 'absent' }
}
}
/**
* Branch name from a `HEAD` file's contents. A symbolic ref names a branch; a
* detached HEAD holds a raw object id and has no branch, which no staging
* pattern can match.
*/
function branchFromHead(head: string): string | undefined {
const trimmed = head.trim()
const ref = 'ref: refs/heads/'
return trimmed.startsWith(ref) ? trimmed.slice(ref.length) : undefined
}
/**
* Resolve the git directory a worktree root's `.git` entry designates, plus
* the shared common directory. A plain clone's `.git` is a directory that is
* its own common dir; a linked worktree's `.git` is a file pointing into the
* main repository's `worktrees/<name>`, whose common dir is two levels up.
* A `gitdir:` pointer may be relative, which git resolves against the worktree
* directory holding it.
*/
async function resolveGitDir(ctx: Context, root: string): Promise<{ gitDir: string; commonDir: string } | undefined> {
const dotGit = resolve(root, '.git')
const entry = await readGitEntry(ctx, dotGit)
// A plain clone keeps a `.git` DIRECTORY, which is both the git dir and the
// common dir; a linked worktree keeps a `.git` FILE pointing elsewhere.
if (entry.kind === 'directory') return { gitDir: dotGit, commonDir: canonicalPath(dotGit) }
if (entry.kind === 'absent') return undefined
const prefix = 'gitdir:'
const trimmed = entry.text.trim()
if (!trimmed.startsWith(prefix)) return undefined
const pointer = trimmed.slice(prefix.length).trim()
if (pointer.length === 0) return undefined
const gitDir = resolve(root, pointer)
// `<common>/worktrees/<name>` — the shared repository is two levels up.
return { gitDir, commonDir: canonicalPath(dirname(dirname(gitDir))) }
}
/**
* Walk from a path toward the filesystem root and resolve the first enclosing
* worktree, or `undefined` when the path is inside none.
*/
async function findWorktree(ctx: Context, from: string): Promise<Worktree | undefined> {
let current = from
for (;;) {
const dirs = await resolveGitDir(ctx, current)
if (dirs !== undefined) {
const head = await readGitEntry(ctx, resolve(dirs.gitDir, 'HEAD'))
return {
branch: head.kind === 'file' ? branchFromHead(head.text) : undefined,
commonDir: dirs.commonDir,
}
}
const parent = dirname(current)
if (parent === current) return undefined
current = parent
}
}
/**
* The skill name a `skill` call's raw argument JSON requested, or `undefined`
* when the JSON is malformed or carries no string `name`. The log stores the
* model's unparsed argument string, so this is a model-JSON boundary.
*/
function skillNameOf(rawArguments: string): string | undefined {
let parsed: unknown
try {
parsed = JSON.parse(rawArguments)
} catch {
// The model produced argument text that is not JSON; the call cannot have
// named a skill. Nothing else in this try can throw.
return undefined
}
if (typeof parsed !== 'object' || parsed === null) return undefined
const value = (parsed as Record<string, unknown>).name
return typeof value === 'string' ? value : undefined
}
/**
* Whether the session's durable log records a successful load of
* `requiredSkill`. Replayed from `tool/call` + `tool/result` pairs, so
* satisfaction survives a session resume: the log is the only state.
*/
function skillLoaded(session: Session, requiredSkill: string): boolean {
const requested = new Map<CallId, string>()
for (const event of session.events) {
if (event.type === 'tool/call') {
if (event.data.name === SKILL_TOOL) requested.set(event.data.callId, event.data.arguments)
continue
}
const block = event.type === 'tool/result' ? event.data.message.content[0] : undefined
if (block === undefined || block.isError === true) continue
const rawArguments = requested.get(block.toolCallId)
if (rawArguments !== undefined && skillNameOf(rawArguments) === requiredSkill) return true
}
return false
}
/** The denial text a blocked call reports to the model. */
function denialReason(path: string, branch: string, requiredSkill: string): string {
return `Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch ${branch}. `
+ `Load the ${requiredSkill} skill first and follow it — implement in a task worktree, then integrate under the staging lock.`
}
/**
* Install the guard's listener.
* @param ctx - plugin context; the listener is scoped to it and disposed with it.
* @param config - validated {@link Config}; re-checked fail-loud here.
*/
export function apply(ctx: Context, config: Config): void {
// schemastery's .default() guarantees the fields are set after validation.
const requiredSkill = config.requiredSkill as string
const tools = config.tools as string[]
if (tools.length === 0) {
throw new Error('source-guard: `tools` must not be empty')
}
if (requiredSkill.trim().length === 0) {
throw new Error('source-guard: `requiredSkill` must not be blank')
}
const gated = new Set(tools)
const protectedCheckout = config.protectedCheckout as string
if (!isAbsolute(protectedCheckout)) {
throw new Error(`source-guard: \`protectedCheckout\` must be an absolute path, got "${protectedCheckout}"`)
}
// Resolved once per plugin lifetime: the worktree this guard arms for, which
// supplies both the protected repository and the protected branch. A harness
// running from an installed copy resolves a different repository (or none)
// and therefore guards nothing, which is correct — the rule is meaningless
// outside a source checkout.
let protectedRepository: Promise<Worktree | undefined> | undefined
/** The repository containing {@link Config.protectedCheckout}. */
function repository(): Promise<Worktree | undefined> {
protectedRepository ??= findWorktree(ctx, dirname(protectedCheckout))
return protectedRepository
}
// Worktree identity per directory, cached for the plugin's lifetime: a
// directory's repository and branch are stable in practice, and re-reading
// git metadata on every write would repeat identical IO. A mid-session
// branch switch is therefore not observed (see the README).
const worktrees = new Map<string, Promise<Worktree | undefined>>()
/** Resolve (and memoize) the worktree enclosing a target path's directory. */
function worktreeOf(path: string): Promise<Worktree | undefined> {
const directory = dirname(path)
let pending = worktrees.get(directory)
if (pending === undefined) {
pending = findWorktree(ctx, directory)
worktrees.set(directory, pending)
}
return pending
}
/**
* The target path and the staging branch protecting it, or `undefined` when
* the call may proceed. Fails open on every unresolvable case: a path outside
* any worktree, a detached HEAD, a different repository, or unreadable git
* metadata leaves the call to the rest of the chain, because a guard that
* blocked writes whenever git identity was unavailable would be worse than
* the violation it prevents.
*/
async function protectedTarget(exec: ToolExecution, session: Session): Promise<{ path: string; branch: string } | undefined> {
if (!gated.has(exec.name)) return undefined
const path = targetPath(exec.arguments, session.header.cwd)
if (path === undefined) return undefined
const launcher = await repository()
// A detached launcher checkout names no branch to protect, so nothing is.
if (launcher?.branch === undefined) return undefined
// Resolution walks OUTWARD from the target, so it reports the INNERMOST
// enclosing worktree: a task worktree nested under the protected tree
// answers with its own task branch, which is not the launcher's. That is
// what keeps the prescribed workflow unblocked.
const worktree = await worktreeOf(path)
if (worktree === undefined || worktree.commonDir !== launcher.commonDir) return undefined
// Only the branch the launcher itself runs from is protected: a stale
// sibling checkout of the same repository is not the live deployment.
if (worktree.branch !== launcher.branch) return undefined
return { path, branch: launcher.branch }
}
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
// A direct `ctx.tools.execute()` caller has no session to replay and no
// model to correct; only agent-loop calls are gated.
if (exec.agent === undefined) return next()
const { session } = exec.agent
const target = await protectedTarget(exec, session)
if (target === undefined) return next()
if (skillLoaded(session, requiredSkill)) return next()
return { kind: 'deny', reason: denialReason(target.path, target.branch, requiredSkill) }
})
}
@@ -1,85 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-source-guard`.
* @module @deepseek-ai/dsh-source-guard/invariant
*/
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
const PACKAGE_NAME = '@deepseek-ai/dsh-source-guard'
/** Cordis companion plugin name. */
export const name = 'source-guard-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* The durable shape of this guard's refusal. The denial is the package's only
* model-visible output, and it is actionable only when it names all three of
* the offending path, the branch that protects it, and the skill that lifts
* the denial — a refusal missing any of them tells the model to stop without
* telling it how to proceed.
*/
const DENIAL = new RegExp(
'^Error: Editing "(?<path>.+)" directly is not allowed: '
+ 'it is inside the dsh checkout this session is running from, on branch (?<branch>\\S+)\\. '
+ 'Load the (?<skill>\\S+) skill first and follow it '
+ '— implement in a task worktree, then integrate under the staging lock\\.$',
)
/** The denial prefix identifying a result this package produced, before its full shape is validated. */
const DENIAL_PREFIX = 'Error: Editing "'
/** Validate one guard-produced denial result's model-facing text. */
function validateDenial(text: string, fail: InvariantFailure): void {
const match = DENIAL.exec(text)
if (match === null) {
fail('source-guard denial must name the path, the protecting branch, and the skill that lifts it')
}
// The pattern's `\S+` groups already establish a non-empty branch and skill;
// only path absoluteness remains to check.
const { path } = match.groups as { path: string }
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
fail(`source-guard denial must name an absolute path, got ${JSON.stringify(path)}`)
}
}
/** Validate every guard denial carried by one session's durable log. */
function validateSession(session: Session, fail: InvariantFailure): void {
for (const event of session.events) {
if (event.type !== 'tool/result') continue
validateEvent(event, fail)
}
}
/** Validate one durable tool result, when it carries this package's denial. */
function validateEvent(event: SessionEvent<'tool/result'>, fail: InvariantFailure): void {
const result = event.data.message.content[0]
if (result.isError !== true) return
for (const block of result.content) {
if (block.type !== 'text' || !block.text.startsWith(DENIAL_PREFIX)) continue
validateDenial(block.text, fail)
}
}
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
/** Install validation for loaded and newly appended denial results. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
for (const session of ctx.sessions.list()) validateSession(session, fail)
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [, event] = args as [Session, SessionEvent]
if (event.type !== 'tool/result') return
validateEvent(event, fail)
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
@@ -1,134 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId, createToolResultMessage, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SourceGuardInvariant from '@deepseek-ai/dsh-source-guard/invariant'
/**
* The companion validates the durable shape of this package's only
* model-visible output: its refusal must name the offending path, the branch
* that protects it, and the skill that lifts it, so the model can act on the
* denial instead of merely stopping.
*/
const PATH = '/repo/staging/file.ts'
/** A well-formed denial for `path`, as the guard materializes it into a tool result. */
function denial(path = PATH, branch = 'dsh-staging/20260101T000000Z', skill = 'dsh-customize'): string {
return `Error: Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, `
+ `on branch ${branch}. Load the ${skill} skill first and follow it `
+ '— implement in a task worktree, then integrate under the staging lock.'
}
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(SourceGuardInvariant)
return ctx
}
/** One durable tool result carrying `content`, error-flagged unless told otherwise. */
function result(content: unknown[], isError = true): SessionEvent {
return {
type: 'tool/result',
seq: 0,
time: 1,
surfaceOp: 'append',
sourceEventSeqs: [0],
data: {
turn: 1,
step: 1,
message: createToolResultMessage({
callId: CallId('c0'),
content: content as ContentBlock[],
isError,
}),
},
}
}
describe('source-guard invariants', () => {
it('accepts a denial naming the path, branch, and skill', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('accept'))
expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text: denial() }])) }).not.toThrow()
})
it('accepts a Windows-style absolute path', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('accept-windows'))
const event = result([{ type: 'text', text: denial(String.raw`C:\repo\staging\file.ts`) }])
expect(() => { ctx.emit('session/event', session, event) }).not.toThrow()
})
it.each([
['a successful result that merely quotes the prefix', false],
])('ignores %s', async (_label, isError) => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('ignore-success'))
const event = result([{ type: 'text', text: 'Error: Editing "x" was fine' }], isError)
expect(() => { ctx.emit('session/event', session, event) }).not.toThrow()
})
it.each([
['a non-text block', [{ type: 'image', data: 'x', mimeType: 'image/png' }]],
['text that is not this package\'s denial', [{ type: 'text', text: 'Error: something else' }]],
])('ignores %s', async (_label, content) => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('ignore-other'))
expect(() => { ctx.emit('session/event', session, result(content)) }).not.toThrow()
})
it('ignores an event that is not a tool result', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('ignore-kind'))
const event: SessionEvent = {
type: 'user/message',
seq: 0,
time: 1,
surfaceOp: 'append',
data: createUserMessage({ content: [{ type: 'text', text: denial() }], source: { kind: 'user' } }),
}
expect(() => { ctx.emit('session/event', session, event) }).not.toThrow()
})
it.each([
[
'omits the skill that lifts it',
`Error: Editing "${PATH}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch main.`,
],
[
'names a relative path',
denial('relative/file.ts'),
],
])('rejects a denial that %s', async (_label, text) => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('reject'))
expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text }])) }).toThrow(/source-guard denial/)
})
it('rejects an invalid denial already present on late registration', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('late'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
const call = session.append('tool/call', {
turn: 1, step: 1, callId: CallId('c0'), name: 'write', arguments: '{}',
})
session.append('tool/result', {
turn: 1,
step: 1,
message: createToolResultMessage({
callId: CallId('c0'),
content: [{ type: 'text', text: denial('relative/file.ts') }],
isError: true,
}),
}, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(SourceGuardInvariant).then(() => undefined)).rejects.toThrow(/source-guard denial/)
})
})
@@ -1,93 +0,0 @@
import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { type SessionEvent } from '@deepseek-ai/dsh-session'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
// The Loader config lives under examples so both launch modes exercise the same
// deployable topology: a local fixture adapter plus bare workspace plugins.
const configPath = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml',
import.meta.url,
))
const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
/** Every `.jsonl` session log under `dir`. */
async function jsonlFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const paths = await Promise.all(entries.map(async (entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return jsonlFiles(path)
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
}))
return paths.flat()
}
/**
* Write git metadata mirroring the installer layout — a master clone owning the
* shared git directory and one linked worktree on a staging branch — and return
* the worktree file the model will try to write.
*/
async function stagingFixture(cwd: string): Promise<{ checkout: string; target: string }> {
const gitDir = join(cwd, 'master', '.git')
const worktreeGitDir = join(gitDir, 'worktrees', 'staging')
await mkdir(worktreeGitDir, { recursive: true })
await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n')
await writeFile(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/dsh-staging/20260101T000000Z\n')
const checkout = join(cwd, 'staging')
await mkdir(checkout, { recursive: true })
await writeFile(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`)
const target = join(checkout, 'guarded.ts')
await writeFile(target, 'original\n')
return { checkout, target }
}
describe('source-guard through a real headless cordis.yml', () => {
it('denies the model-requested write and leaves the staged file untouched', async () => {
let events: SessionEvent[] = []
let contents = ''
let target = ''
const { stderr } = await runLoaderSmoke({
label: 'source-guard headless smoke',
tempDirPrefix: 'source-guard-e2e-',
binScript,
configPath,
tsconfigPath: repoTsconfig,
binArgs: ['--config', configPath, 'edit the guarded file'],
// The isolated cwd is not known when these options are built, so the
// config and adapter resolve their fixture paths against the child's own
// cwd, which is that directory.
prepare: async (cwd) => {
// macOS puts the temp directory behind the /var -> /private/var
// symlink; the child resolves its cwd, so compare against the same
// real path rather than the symlinked one this process was handed.
target = (await stagingFixture(await realpath(cwd))).target
},
inspect: async (cwd) => {
const logs = await jsonlFiles(join(cwd, '.sessions'))
expect(logs).toHaveLength(1)
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
contents = await readFile(target, 'utf8')
},
})
expect(stderr).not.toContain('UNHANDLED')
const results = events.filter(
(event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
expect(results).toHaveLength(1)
const result = results[0]?.data.message.content[0]
expect(result?.isError).toBe(true)
const text = result?.content.map(block => block.type === 'text' ? block.text : '').join('')
expect(text).toBe(
`Error: Editing "${target}" directly is not allowed: it is inside the dsh checkout this session is running from, `
+ 'on branch dsh-staging/20260101T000000Z. Load the dsh-customize skill first and follow it '
+ '— implement in a task worktree, then integrate under the staging lock.',
)
// Enforcement, not advice: the guard denies before dispatch, so the file
// the model targeted still holds its original bytes.
expect(contents).toBe('original\n')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
@@ -1,581 +0,0 @@
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { CallId, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as SourceGuard from '@deepseek-ai/dsh-source-guard'
import type { Config } from '@deepseek-ai/dsh-source-guard'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
* Behavior suite for the staging-source guard: worktree resolution over REAL
* git metadata fixtures (a staging worktree, a nested task worktree, a plain
* clone, an unrelated repository, a detached HEAD), skill satisfaction replayed
* from the durable session log, and fail-loud config validation — all driven
* through a real agent loop against a scripted mock adapter (no network).
*/
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
/**
* Build a git-metadata fixture tree that mirrors the real installer layout: a
* `master` clone holding the shared git directory, linked worktrees registered
* under `master/.git/worktrees/<name>`, and one file per worktree to target.
*/
async function fixture(): Promise<{
/** Absolute path of the fixture container. */
root: string
/** A file inside the staging worktree — the protected target. */
stagingFile: string
/** A file inside a task worktree NESTED under the staging tree. */
taskFile: string
/** A file inside a SIBLING staging worktree of the same repository, on another branch. */
siblingFile: string
/** A file inside the plain master clone. */
masterFile: string
/** A file inside a worktree whose HEAD is detached. */
detachedFile: string
/** A file inside an unrelated repository sharing no git directory. */
outsideFile: string
/** A file under no repository at all. */
looseFile: string
}> {
const root = await mkdtemp(join(tmpdir(), 'source-guard-'))
roots.push(root)
const master = join(root, 'master')
const gitDir = join(master, '.git')
await mkdir(join(gitDir, 'worktrees'), { recursive: true })
await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n')
await writeFile(join(master, 'file.ts'), 'master\n')
/** Register one linked worktree at `path` whose HEAD file holds `head`. */
async function linked(path: string, name: string, head: string): Promise<string> {
const worktreeGitDir = join(gitDir, 'worktrees', name)
await mkdir(worktreeGitDir, { recursive: true })
await writeFile(join(worktreeGitDir, 'HEAD'), head)
await mkdir(path, { recursive: true })
await writeFile(join(path, '.git'), `gitdir: ${worktreeGitDir}\n`)
const file = join(path, 'file.ts')
await writeFile(file, 'content\n')
return file
}
const staging = join(root, 'staging-20260728T022827Z')
const stagingFile = await linked(staging, 'staging-20260728T022827Z', 'ref: refs/heads/dsh-staging/20260728T022827Z\n')
// The prescribed workflow's task worktree lives INSIDE the staging tree.
const taskFile = await linked(join(staging, '.worktrees', 'task', 'x'), 'task-x', 'ref: refs/heads/task/x\n')
// A stale staging worktree from an earlier install: same repository, different branch.
const siblingFile = await linked(
join(root, 'staging-20260727T045831Z'),
'staging-20260727T045831Z',
'ref: refs/heads/dsh-staging/20260727T045831Z\n',
)
const detachedFile = await linked(join(root, 'detached'), 'detached', '0123456789abcdef0123456789abcdef01234567\n')
const outside = join(root, 'outside')
await mkdir(join(outside, '.git'), { recursive: true })
await writeFile(join(outside, '.git', 'HEAD'), 'ref: refs/heads/dsh-staging/20260728T022827Z\n')
const outsideFile = join(outside, 'file.ts')
await writeFile(outsideFile, 'outside\n')
const loose = join(root, 'loose')
await mkdir(loose, { recursive: true })
const looseFile = join(loose, 'file.ts')
await writeFile(looseFile, 'loose\n')
return {
root, stagingFile, taskFile, siblingFile, masterFile: join(master, 'file.ts'), detachedFile, outsideFile, looseFile,
}
}
/**
* Boot the core spine, a real local filesystem, and the guard, pointing
* `protectedCheckout` at a fixture path so the guard arms for the fixture
* repository instead of the checkout these tests actually run in.
*/
async function harness(protectedCheckout: string, config: Partial<Config> = {}): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(LocalFileSystem, {})
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SourceGuard, { ...config, protectedCheckout })
for (const name of ['write', 'edit', 'read', 'skill']) {
ctx.tools.register(defineContentToolFixture({
name,
description: name,
parameters: { file_path: { type: 'string' }, name: { type: 'string' } },
async execute() { return [{ type: 'text', text: 'ok' }] },
}))
}
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
})
})
}
/** Every tool result in the agent's log as `{ isError, text }`, in log order. */
function results(agent: Agent): { isError: boolean; text: string }[] {
return [...agent.session.events]
.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
.map(event => event.data.message.content[0])
.map(result => ({
isError: result.isError === true,
text: result.content.map(block => block.type === 'text' ? block.text : '').join(''),
}))
}
/**
* Durable events recording completed `skill` calls, as a RESUMED session's seed:
* the guard's satisfaction check then has nothing but the log to read, with no
* in-memory state from an original run to fall back on.
*/
function priorSkillCalls(calls: { arguments: string; isError?: boolean }[]): SessionEvent[] {
const events: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
]
for (const [index, call] of calls.entries()) {
const callId = CallId(`prior${index}`)
const seq = events.length
events.push({
type: 'tool/call',
seq,
time: seq + 1,
data: { turn: 1, step: 1, callId, name: 'skill', arguments: call.arguments },
})
events.push({
type: 'tool/result',
seq: seq + 1,
time: seq + 2,
surfaceOp: 'append',
sourceEventSeqs: [seq],
data: {
turn: 1,
step: 1,
message: createToolResultMessage({
callId,
content: [{ type: 'text', text: 'loaded' }],
isError: call.isError ?? false,
}),
},
})
}
const tail = events.length
events.push({ type: 'step/end', seq: tail, time: tail + 1, data: { turn: 1, step: 1 } })
events.push({ type: 'turn/end', seq: tail + 1, time: tail + 2, data: { turn: 1, reason: { kind: 'completed' } } })
return events
}
/** Resume a session from durable seed events and let the model attempt one write at `path`. */
async function resume(ctx: Context, id: string, seed: SessionEvent[], path: string): Promise<Agent> {
const adapter = new MockAdapter([
toolCallResponse(CallId('c0'), 'write', { file_path: path }),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const { agent } = await ctx.agentLoop.createAgent(ctx, {
sessionId: SessionId(id),
seed,
agentOptions: { provider: 'mock', model: 'mock' },
})
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
return agent
}
/** Drive one turn whose scripted model output is the given tool calls, then a closing text. */
async function run(
ctx: Context,
calls: { name: string; args: Record<string, unknown> }[],
cwd?: string,
): Promise<Agent> {
const adapter = new MockAdapter([
...calls.map((call, index) => toolCallResponse(CallId(`c${index}`), call.name, call.args)),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(
SessionId('s1'),
{ provider: 'mock', model: 'mock' },
cwd === undefined ? {} : { cwd },
)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
return agent
}
describe('staging protection', () => {
it('denies a write inside the staging worktree and names the path, branch, and skill', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
const [result] = results(agent)
expect(result?.isError).toBe(true)
expect(result?.text).toBe(
`Error: Editing "${paths.stagingFile}" directly is not allowed: it is inside the dsh checkout this session is running from, `
+ 'on branch dsh-staging/20260728T022827Z. Load the dsh-customize skill first and follow it '
+ '— implement in a task worktree, then integrate under the staging lock.',
)
})
it('denies an edit inside the staging worktree', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'edit', args: { file_path: paths.stagingFile } }])
expect(results(agent)[0]?.isError).toBe(true)
})
it('allows a read inside the staging worktree, since inspection never violates the skill', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'read', args: { file_path: paths.stagingFile } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('allows a write inside a task worktree nested under the staging tree', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('allows a write in the plain clone that owns the shared git directory', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.masterFile } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('allows a write on a staging-named branch in an unrelated repository', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.outsideFile } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('allows a write under a detached HEAD, which names no branch to match', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.detachedFile } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('allows a write when the git metadata exists but cannot be read', async () => {
const paths = await fixture()
// A `.git` pointer that stats as a file yet fails to read leaves the guard
// with no branch to judge; failing open beats blocking every edit.
const unreadable = join(paths.root, 'unreadable')
await mkdir(unreadable, { recursive: true })
await writeFile(join(unreadable, '.git'), `gitdir: ${join(paths.root, 'master', '.git')}\n`)
await chmod(join(unreadable, '.git'), 0o000)
const file = join(unreadable, 'file.ts')
await writeFile(file, 'content\n')
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('allows a write under no repository at all', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.looseFile } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('arms for nothing when its own location is inside no repository', async () => {
const paths = await fixture()
const ctx = await harness(paths.looseFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('arms for nothing when the launcher checkout has a detached HEAD', async () => {
const paths = await fixture()
// A detached launcher names no branch, so there is no branch to protect.
const ctx = await harness(paths.detachedFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('denies when the target and the protected checkout reach one repository through different symlinks', async () => {
const paths = await fixture()
// macOS reaches the temp directory through both `/var/...` and
// `/private/var/...`; a lexical repository comparison would treat the two
// routes as different repositories and fail open on every write.
const link = join(paths.root, 'link')
await symlink(dirname(paths.stagingFile), link, 'dir')
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: join(link, 'file.ts') } }])
expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z')
})
it('denies a RELATIVE target path resolved against the session workspace', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
// The filesystem tools resolve a relative `file_path` against the session
// cwd, so judging only absolute paths would leave this as an unguarded
// route to the same file.
const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }], dirname(paths.stagingFile))
expect(results(agent)[0]?.text).toContain('directly is not allowed')
})
it('ignores a relative target path when the session names no workspace', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('ignores a call whose target path is an empty string', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: '' } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it.each([
['a non-string file_path', { file_path: 7 }],
['no file_path at all', { other: 'x' }],
])('ignores a gated call carrying %s', async (_label, args) => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args }])
expect(results(agent)[0]?.text).not.toContain('directly is not allowed')
})
it('ignores a gated call whose arguments are not JSON, which the loop keeps as raw text', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const callId = CallId('raw')
const adapter = new MockAdapter([
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: 'not json' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: 'not json' } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
],
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('raw'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(results(agent)[0]?.text).not.toContain('directly is not allowed')
})
it.each([
['a `.git` pointer that names no git directory', 'not a gitdir pointer\n'],
['an empty `.git` pointer', 'gitdir:\n'],
['a `.git` pointer into a nonexistent git directory', 'gitdir: /nonexistent/worktrees/x\n'],
])('allows a write behind %s', async (_label, pointer) => {
const paths = await fixture()
const broken = join(paths.root, 'broken')
await mkdir(broken, { recursive: true })
await writeFile(join(broken, '.git'), pointer)
const file = join(broken, 'file.ts')
await writeFile(file, 'content\n')
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('denies behind a RELATIVE `.git` pointer, which git resolves against the worktree', async () => {
const paths = await fixture()
// `git worktree add` writes an absolute pointer, but a relocated or
// hand-written one may be relative; git accepts both, so the guard must
// resolve both or it would fail open on a real repository layout.
const relative = join(paths.root, 'relative-pointer')
await mkdir(relative, { recursive: true })
await writeFile(join(relative, '.git'), 'gitdir: ../master/.git/worktrees/staging-20260728T022827Z\n')
const file = join(relative, 'file.ts')
await writeFile(file, 'content\n')
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z')
})
it('allows a write when the worktree resolves but its HEAD is missing', async () => {
const paths = await fixture()
const gitDir = join(paths.root, 'master', '.git', 'worktrees', 'headless')
await mkdir(gitDir, { recursive: true })
const headless = join(paths.root, 'headless')
await mkdir(headless, { recursive: true })
await writeFile(join(headless, '.git'), `gitdir: ${gitDir}\n`)
const file = join(headless, 'file.ts')
await writeFile(file, 'content\n')
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('reuses one resolution for sibling targets in the same directory', async () => {
const paths = await fixture()
const sibling = join(dirname(paths.stagingFile), 'other.ts')
await writeFile(sibling, 'content\n')
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [
{ name: 'write', args: { file_path: paths.stagingFile } },
{ name: 'write', args: { file_path: sibling } },
])
expect(results(agent).map(result => result.isError)).toEqual([true, true])
})
it('protects whichever branch the launcher checkout is on, whatever its name', async () => {
const paths = await fixture()
// The protected branch is read from `protectedCheckout`'s own worktree, so
// a checkout on an unconventional branch name is still protected — a
// hardcoded name pattern would have silently guarded nothing.
const ctx = await harness(paths.taskFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }])
expect(results(agent)[0]?.text).toContain('on branch task/x')
})
it('allows a write in a SIBLING checkout of the same repository on another branch', async () => {
const paths = await fixture()
// A stale staging worktree left by an earlier install shares the
// repository but is not the live deployment, so the workflow rule the
// guard enforces does not apply to it.
const ctx = await harness(paths.siblingFile)
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
it('gates only the configured tools', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile, { tools: ['edit'] })
const agent = await run(ctx, [
{ name: 'write', args: { file_path: paths.stagingFile } },
{ name: 'edit', args: { file_path: paths.stagingFile } },
])
expect(results(agent).map(result => result.isError)).toEqual([false, true])
})
})
describe('skill satisfaction', () => {
it('allows the write after a successful load of the required skill in the same turn', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [
{ name: 'skill', args: { name: 'dsh-customize' } },
{ name: 'write', args: { file_path: paths.stagingFile } },
])
expect(results(agent)).toEqual([
{ isError: false, text: 'ok' },
{ isError: false, text: 'ok' },
])
})
it('allows the write when the skill load is only in the REPLAYED log, so resume keeps satisfaction', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }) }])
const agent = await resume(ctx, 'resumed', seed, paths.stagingFile)
expect(results(agent).at(-1)).toEqual({ isError: false, text: 'ok' })
})
it('does not accept a failed skill load', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }), isError: true }])
const agent = await resume(ctx, 'failed', seed, paths.stagingFile)
expect(results(agent).at(-1)?.isError).toBe(true)
})
it('does not accept a different skill', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const agent = await run(ctx, [
{ name: 'skill', args: { name: 'dsh-upgrade' } },
{ name: 'write', args: { file_path: paths.stagingFile } },
])
expect(results(agent).map(result => result.isError)).toEqual([false, true])
})
it('does not accept a skill call whose arguments are not a JSON object naming a string', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const seed = priorSkillCalls([
{ arguments: 'not json' },
{ arguments: '[]' },
{ arguments: '{"name":7}' },
{ arguments: 'null' },
])
const agent = await resume(ctx, 'malformed', seed, paths.stagingFile)
expect(results(agent).at(-1)?.isError).toBe(true)
})
it('honours a configured skill name other than the default', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile, { requiredSkill: 'other-skill' })
const agent = await run(ctx, [
{ name: 'skill', args: { name: 'other-skill' } },
{ name: 'write', args: { file_path: paths.stagingFile } },
])
expect(results(agent).map(result => result.isError)).toEqual([false, false])
})
})
describe('non-agent callers', () => {
it('leaves a direct registry call ungated, having no session to replay', async () => {
const paths = await fixture()
const ctx = await harness(paths.stagingFile)
const result = await ctx.tools.execute({
callId: CallId('direct'),
name: 'write',
arguments: { file_path: paths.stagingFile },
signal: new AbortController().signal,
})
expect(result.isError).toBe(false)
})
})
describe('config validation', () => {
it.each([
['tools', { tools: [] }, '`tools` must not be empty'],
['requiredSkill', { requiredSkill: ' ' }, '`requiredSkill` must not be blank'],
['protectedCheckout', { protectedCheckout: 'relative/path' }, '`protectedCheckout` must be an absolute path'],
])('rejects an invalid %s at load', async (_field, config, message) => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(LocalFileSystem, {})
await expect(ctx.plugin(SourceGuard, config as Config)).rejects.toThrow(message)
})
})
describe('disposal', () => {
it('stops gating once the plugin fiber is disposed', async () => {
const paths = await fixture()
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(LocalFileSystem, {})
await ctx.plugin(AgentLoop, { agents: [] })
const fiber = await ctx.plugin(SourceGuard, { protectedCheckout: paths.stagingFile })
for (const name of ['write', 'skill']) {
ctx.tools.register(defineContentToolFixture({
name,
description: name,
parameters: { file_path: { type: 'string' }, name: { type: 'string' } },
async execute() { return [{ type: 'text', text: 'ok' }] },
}))
}
await fiber.dispose()
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
})
})
-42
View File
@@ -1,42 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/tools"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../fs/fs"
},
{
"path": "../../llm/llm"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../support/invariants"
}
]
}
-86
View File
@@ -296,9 +296,6 @@ importers:
'@deepseek-ai/dsh-skill-local':
specifier: workspace:^
version: link:../../packages/skill/skill-local
'@deepseek-ai/dsh-source-guard':
specifier: workspace:^
version: link:../../packages/guard/source-guard
'@deepseek-ai/dsh-spill-local':
specifier: workspace:^
version: link:../../packages/spill/spill-local
@@ -335,9 +332,6 @@ importers:
'@deepseek-ai/dsh-timeout-policy':
specifier: workspace:^
version: link:../../packages/timeout/timeout-policy
'@deepseek-ai/dsh-tmux-context':
specifier: workspace:^
version: link:../../packages/context/tmux-context
'@deepseek-ai/dsh-token-meter':
specifier: workspace:^
version: link:../../packages/llm/token-meter
@@ -613,9 +607,6 @@ importers:
'@deepseek-ai/dsh-skill-local':
specifier: workspace:*
version: link:../packages/skill/skill-local
'@deepseek-ai/dsh-source-guard':
specifier: workspace:*
version: link:../packages/guard/source-guard
'@deepseek-ai/dsh-spill-local':
specifier: workspace:*
version: link:../packages/spill/spill-local
@@ -652,9 +643,6 @@ importers:
'@deepseek-ai/dsh-timeout-policy':
specifier: workspace:*
version: link:../packages/timeout/timeout-policy
'@deepseek-ai/dsh-tmux-context':
specifier: workspace:*
version: link:../packages/context/tmux-context
'@deepseek-ai/dsh-token-meter':
specifier: workspace:*
version: link:../packages/llm/token-meter
@@ -1963,37 +1951,6 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/context/tmux-context:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-bash':
specifier: workspace:^
version: link:../../bash/bash
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-loader-smoke':
specifier: workspace:^
version: link:../../support/loader-smoke
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/context/workspace-context:
dependencies:
schemastery:
@@ -2784,49 +2741,6 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/guard/source-guard:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
'@deepseek-ai/dsh-agent-loop-testkit':
specifier: workspace:^
version: link:../../support/agent-loop-testkit
'@deepseek-ai/dsh-fs':
specifier: workspace:^
version: link:../../fs/fs
'@deepseek-ai/dsh-fs-local':
specifier: workspace:^
version: link:../../fs/fs-local
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-loader-smoke':
specifier: workspace:^
version: link:../../support/loader-smoke
'@deepseek-ai/dsh-sandbox':
specifier: workspace:^
version: link:../../sandbox/sandbox
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/hooks/hook-protocol:
devDependencies:
'@deepseek-ai/dsh-bash':
-2
View File
@@ -86,7 +86,6 @@
{ "path": "./packages/goal/goal-session" },
{ "path": "./packages/goal/command-goal" },
{ "path": "./packages/context/time-context" },
{ "path": "./packages/context/tmux-context" },
{ "path": "./packages/context/session-reference" },
{ "path": "./packages/ui/user-interaction" },
{ "path": "./packages/ui/user-approval" },
@@ -165,7 +164,6 @@
{ "path": "./packages/todo/tool-todo" },
{ "path": "./packages/plan/plan-mode" },
{ "path": "./packages/guard/repeat-tool-guard" },
{ "path": "./packages/guard/source-guard" },
{ "path": "./packages/cordis/tool-cordis" },
{ "path": "./packages/hooks/hook-protocol" },
{ "path": "./packages/hooks/hooks-claude" },