From 1ac946f747e809cced467ef03f8fd2754db2692a Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 28 Jul 2026 21:29:24 +0800 Subject: [PATCH 1/8] feat(context): add tmux-context plugin injecting the agent's tmux location Add @deepseek-ai/dsh-tmux-context: an opt-in per-turn context plugin that reads which tmux session/window/pane this agent process runs in (plus the window layout tree) via the ctx.bash seam, and injects it as one durable, source-attributed user/message when the location changes. - Pull on the first step of each turn; no tmux hook or background process. - Detect a real pane by tty, not $TMUX_PANE alone: a terminal launched from a tmux shell inherits $TMUX/$TMUX_PANE from that ancestor, so the command also matches the pane's #{pane_tty} against this process's controlling terminal and emits fields only on a match. - No-op outside a real pane, without a bash executor, or on a malformed reading. - Own location and layout only: no pane sizes, no sibling-pane scraping. - Unit tests at 100% per-file coverage, plus a keyless Loader e2e with a mock bash provider so it replays without tmux. - Agent Note: 2026-07-27-tmux-location-context. --- ...2026-07-27-tmux-location-context.i18n.yaml | 6 + .../2026-07-27-tmux-location-context.md | 61 +++ .../2026-07-27-tmux-location-context.zh.md | 61 +++ docs/config-catalog.md | 14 + docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 6 + .../tests/fixtures/tmux-context-driver.ts | 16 + .../tests/fixtures/tmux-context-mock-bash.ts | 46 +++ .../tests/fixtures/tmux-context-mock-llm.ts | 22 ++ .../tests/fixtures/tmux-context.cordis.yml | 21 + examples/package.json | 2 + knip.json | 13 + packages/context/README.i18n.yaml | 6 +- packages/context/README.md | 3 +- packages/context/README.zh.md | 3 +- .../context/tmux-context/README.i18n.yaml | 6 + packages/context/tmux-context/README.md | 68 ++++ packages/context/tmux-context/README.zh.md | 68 ++++ packages/context/tmux-context/package.json | 49 +++ packages/context/tmux-context/src/index.ts | 227 +++++++++++ .../context/tmux-context/src/invariant.ts | 30 ++ .../tmux-context/tests/tmux-context.e2e.ts | 77 ++++ .../tmux-context/tests/tmux-context.spec.ts | 365 ++++++++++++++++++ packages/context/tmux-context/tsconfig.json | 40 ++ pnpm-lock.yaml | 37 ++ tsconfig.host.json | 1 + 26 files changed, 1244 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-driver.ts create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts create mode 100644 examples/headless-agent/tests/fixtures/tmux-context.cordis.yml create mode 100644 packages/context/tmux-context/README.i18n.yaml create mode 100644 packages/context/tmux-context/README.md create mode 100644 packages/context/tmux-context/README.zh.md create mode 100644 packages/context/tmux-context/package.json create mode 100644 packages/context/tmux-context/src/index.ts create mode 100644 packages/context/tmux-context/src/invariant.ts create mode 100644 packages/context/tmux-context/tests/tmux-context.e2e.ts create mode 100644 packages/context/tmux-context/tests/tmux-context.spec.ts create mode 100644 packages/context/tmux-context/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml new file mode 100644 index 0000000000..29817b7a69 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md +2026-07-27-tmux-location-context.md: 9436e9bcf764a505a4da3c8f5ef1d6313ba648d3 +2026-07-27-tmux-location-context.zh.md: 09cbf056a577918bc8f9682843154b8dc1bda5a0 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md new file mode 100644 index 0000000000..9436e9bcf7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md @@ -0,0 +1,61 @@ +# Agent Note: tmux-location context + +Status: implemented + +English | [中文](2026-07-27-tmux-location-context.zh.md) + +## Problem + +An agent running inside tmux has no way to tell the model where it is: which session, window, and pane the process occupies, and how the window is laid out. A user directing several panes wants the model to orient itself to its own location so instructions like "the pane below" or "this window" resolve. The location must reach the model as durable, reconstructable context, not a system-prompt value rewritten in place, and must cost nothing when the location has not changed. + +tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and `tmux display-message -t "$TMUX_PANE" -p ''` prints any pane/window/session field. The open question was how to observe it — pull on each preparation, or push from a tmux hook — and how to avoid a per-step token cost and hidden process-local state. + +## Decision + +`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy. + +**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. + +**Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider. + +**Detect a real pane by tty, not by `$TMUX_PANE` alone.** `$TMUX_PANE` is inherited: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) carries `$TMUX`/`$TMUX_PANE` from that ancestor even though the process does not live in that pane, which otherwise injects a stale, wrong location. The command resolves this process's controlling terminal with `ps -o tty= -p ` (the agent's own pid, passed in-process) and compares it to the pane's `#{pane_tty}`; fields are emitted only on a match. A genuine pane owns this process's tty; an inherited environment names some other pane's tty and reads as "not in tmux". Checking `$TMUX` instead does not help — it is inherited identically. This is the definitive discriminator and needs no allowlist of terminal emulators. + +**Own location and layout only.** The queried fields are session name, window index/name, pane index/id, window/pane active flags, and `window_layout`. Pane and window pixel sizes are excluded (layout tree conveys structure; sizes are noisy and change on every terminal resize). Sibling-pane contents are never captured (`capture-pane`), keeping the reading small and avoiding scraping unrelated, possibly sensitive, output. + +**Inject only on change, with optional interval floor.** When due, the plugin calls `agent.inject()` for one `user/message` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression compares the rendered state block (everything after the turn preamble line) against the latest injection of this source, found by scanning raw durable session events — so the schedule survives compaction and process resume without a process-local cache. The optional `refreshIntervalMs` (manually validated as a non-negative safe integer at plugin load) additionally suppresses injections within that window of the latest one. + +### Text + +```text +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +The turn preamble is the volatile first line; the two-line state block below it is the unit compared for change suppression, so re-injection is driven by tmux state, not loop position. + +### Durability and request reconstruction + +Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback. + +The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests. + +## Consequences + +An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. Deployments opt in through cordis.yml; the default spine and shipped examples stay silent. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one `tmux display-message` subprocess (through the sandboxed bash seam) on the first step of each turn that is due; unchanged locations and the optional interval floor suppress both the query and the injection. + +## Testing + +Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%. + +## Alternatives considered + +- **Push from a tmux hook / background watcher** — rejected: requires installing hooks in the user's tmux and a background process with teardown, to gain mid-step freshness that per-turn context does not need. +- **Run every step** — rejected: location is stable within a turn; re-querying adds token cost without new information. Gating on `step === 1` yields per-turn readings. +- **Raw `child_process`** — rejected: bypasses the sandbox/policy seam and hand-rolls subprocess code the `ctx.bash` executor already owns. +- **Include pane/window pixel sizes** — rejected: sizes churn on every resize and add noise; the layout tree already conveys structure. +- **Scrape sibling panes with `capture-pane`** — rejected: large, noisy, and privacy-sensitive; out of scope for "own location". +- **Dynamic system-prompt section** — rejected: replacing a value erases the earlier readings behind prior reasoning and is not reconstructable; one durable attributed message records each location where it became visible. +- **Trust `$TMUX_PANE` (or `$TMUX`) presence** — rejected: both are inherited by terminals launched from a tmux shell (VS Code integrated terminal), so a non-pane process injects a stale location. The pane `#{pane_tty}` vs. this process's controlling tty is the definitive check. +- **Denylist known terminal emulators (e.g. `TERM_PROGRAM=vscode`)** — rejected: a partial, ever-growing list that still misses other launchers; the tty match is exact and launcher-agnostic. +- **A runtime invariant validating each reading's turn, position, and format** — shipped initially, then removed: it re-derived the producer's own scheduling from the log and asserted a regex over text the same package had just rendered, so it restated `apply()` rather than checking an independent relation. Every failure it could report required an edit to this package, which its pipeline tests already catch. Reintroduce a companion check only for a relation the plugin does not itself compute — for example if readings gain cross-turn ordering or enclosure obligations that another package can violate. diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md new file mode 100644 index 0000000000..09cbf056a5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md @@ -0,0 +1,61 @@ +# Agent Note:tmux 位置上下文 + +Status: implemented + +[English](2026-07-27-tmux-location-context.md) | 中文 + +## 问题 + +运行在 tmux 内的 agent 无法告诉模型自己身在何处:进程占据哪个 session、window、pane,以及 window 如何布局。当用户操作多个 pane 时,希望模型能对自身位置有所定位,从而让"下方的 pane""这个 window"之类的指令得以解析。位置必须以持久、可重建的上下文形式送达模型,而非在原地被改写的系统提示值,并且当位置未变化时不产生任何成本。 + +tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所在 pane,`tmux display-message -t "$TMUX_PANE" -p ''` 可打印任意 pane/window/session 字段。待决问题在于如何观测——在每次准备时拉取,还是由 tmux hook 推送——以及如何避免逐步骤 token 成本与隐藏的进程内状态。 + +## 决策 + +`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。 + +**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 + +**通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux/`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。 + +**以 tty 判定真实 pane,而非仅凭 `$TMUX_PANE`。** `$TMUX_PANE` 会被继承:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程带上 `$TMUX`/`$TMUX_PANE`,即使进程并不位于那个 pane 中,否则就会注入一个陈旧且错误的位置。命令用 `ps -o tty= -p `(在进程内传入 agent 自身的 pid)解析本进程的控制终端,并与 pane 的 `#{pane_tty}` 比较;只有匹配时才输出字段。真正的 pane 拥有本进程的 tty;继承而来的环境指向的是另一个 pane 的 tty,因而被读作"不在 tmux 中"。改为检查 `$TMUX` 也无济于事——它同样会被继承。这是决定性的判别依据,且无需维护终端模拟器名单。 + +**仅自身位置与布局。** 查询字段为 session name、window index/name、pane index/id、window/pane 活动标志以及 `window_layout`。省略 pane 与 window 像素尺寸(布局树已传达结构;尺寸嘈杂且每次终端缩放都会变化)。从不采集相邻 pane 内容(`capture-pane`),使读数保持小巧,并避免抓取无关、可能敏感的输出。 + +**仅在变化时注入,并可选间隔下限。** 需要时,插件调用 `agent.inject()` 注入一条来源为 `{ kind: 'plugin', plugin: 'tmux-context' }` 的 `user/message`。变化抑制将渲染出的状态块(轮次前缀行之后的全部内容)与该来源的最近一次注入比较,后者通过扫描原始持久会话事件获得——因此调度可跨压缩与进程恢复存续,无需进程内缓存。可选的 `refreshIntervalMs`(在插件加载时手动校验为非负安全整数)会额外抑制距最近一次注入不足该窗口的注入。 + +### 文本 + +```text +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +轮次前缀是易变的首行;其下的两行状态块才是变化抑制所比较的单元,因此重新注入由 tmux 状态驱动,而非循环位置。 + +### 持久性与请求重建 + +每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。 + +发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。 + +## 后果 + +启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 `tmux display-message` 子进程(经沙箱化的 bash seam);位置未变化以及可选的间隔下限会同时抑制查询与注入。 + +## 测试 + +单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 + +## 考虑过的替代方案 + +- **由 tmux hook / 后台监视器推送**——否决:需要在用户的 tmux 中安装 hook,并引入带清理的后台进程,只为换取按轮次上下文并不需要的步内新鲜度。 +- **每个 step 都运行**——否决:位置在一轮内稳定;重复查询只增加 token 成本而无新信息。以 `step === 1` 为门槛得到按轮次读数。 +- **裸 `child_process`**——否决:绕过沙箱/策略 seam,并手写 `ctx.bash` 执行器已拥有的子进程代码。 +- **包含 pane/window 像素尺寸**——否决:尺寸每次缩放都变动、徒增噪声;布局树已传达结构。 +- **用 `capture-pane` 抓取相邻 pane**——否决:庞大、嘈杂且涉及隐私;超出"自身位置"范围。 +- **动态系统提示区块**——否决:替换某个值会抹去支撑先前推理的历史读数且不可重建;单条持久且带来源的消息在每个位置变得可见时予以记录。 +- **信任 `$TMUX_PANE`(或 `$TMUX`)存在即可**——否决:两者都会被从 tmux shell 启动的终端(VS Code 集成终端)继承,于是非 pane 进程会注入陈旧位置。pane 的 `#{pane_tty}` 与本进程控制终端的比对才是决定性检查。 +- **对已知终端模拟器设黑名单(如 `TERM_PROGRAM=vscode`)**——否决:名单不完整且会不断增长,仍会漏掉其他启动器;tty 比对精确且与启动器无关。 +- **用运行时 invariant 校验每条读数的轮次、位置与格式**——最初随包发布,随后移除:它从日志中重新推导生产者自身的调度,并对同一个包刚刚渲染出的文本断言正则,因此只是重述 `apply()`,而非检查一条独立关系。它能报出的每种失败都必须先修改本包,而这些本包的管线测试已经覆盖。仅当出现插件自身并不计算的关系时才重新引入伴生检查——例如读数将来具备可被其他包破坏的跨轮次顺序或包裹义务。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 676054c215..95bfde61a5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1487,6 +1487,20 @@ export interface Config { Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts) +## `@deepseek-ai/dsh-tmux-context` + +Requires: `agents` + +```ts config-catalog +/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ +export interface Config { + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ + refreshIntervalMs?: number +} +``` + +Source: [`packages/context/tmux-context/src/index.ts:33`](../packages/context/tmux-context/src/index.ts) + ## `@deepseek-ai/dsh-token-meter` ```ts config-catalog diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index dabfeb2517..6a360bd570 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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), [`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), [`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/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) | | `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) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 729745f7ad..b2c74cb0bd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -168,6 +168,7 @@ flowchart TD subgraph group_context["packages/context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] + pkg_tmux_context["tmux-context"] pkg_workspace_context["workspace-context"] end subgraph group_examples["packages/examples"] @@ -497,6 +498,10 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -1001,6 +1006,7 @@ 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) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | diff --git a/examples/headless-agent/tests/fixtures/tmux-context-driver.ts b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts new file mode 100644 index 0000000000..2fd6d0f5ec --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +/** Test driver that sends two turns through one Headless Loader composition. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('tmux-context driver requires a config path') + +const ctx = await boot('tmux-context-e2e', resolveConfigPath(configPath, undefined)) +try { + await runOneShot(ctx, { task: 'first' }) + await runOneShot(ctx, { task: 'second' }) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts new file mode 100644 index 0000000000..3e9540abff --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts @@ -0,0 +1,46 @@ +import type { Context } from 'cordis' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' + +/** + * Deterministic `ctx.bash` for the tmux-context Loader fixture: any command + * (the plugin's `tmux display-message`) returns a fixed tab-delimited reading, + * so the injected tmux location is stable without a real tmux server. `start()` + * throws — tmux-context must never spawn a background process. + */ +class TmuxMockBash extends BashExecutor { + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + sandboxPolicy: request.sandboxPolicy, + } + } + + override run(_spec: BashExecSpec): Promise { + const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t') + return Promise.resolve({ + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: `${line}\n`, truncated: false }, + stderr: { text: '', truncated: false }, + }) + } + + override start(): BashProcess { + throw new Error('tmux-context must never start a background task') + } +} + +export const name = 'tmux-context-mock-bash' + +/** Register the deterministic `ctx.bash` executor for the fixture. */ +export function apply(ctx: Context): void { + ctx.plugin(TmuxMockBash) +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts new file mode 100644 index 0000000000..2f6c7a6408 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts @@ -0,0 +1,22 @@ +import type { Context } from 'cordis' +import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' + +/** Deterministic one-step adapter for the tmux-context Loader fixture. */ +class TmuxContextMockAdapter extends LlmAdapter { + async * stream(): AsyncIterable { + const text = 'tmux context sampled' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'tmux-context-mock-llm' +export const inject = ['llm'] + +/** Register the test-only `tmux-context-mock` adapter. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter()) +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml new file mode 100644 index 0000000000..419922a0e3 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml @@ -0,0 +1,21 @@ +# Test-only composition: keep tmux-context opt-in while exercising its real Loader/app path. +# A deterministic mock ctx.bash returns a fixed tmux reading, so the injected location +# is stable without a real tmux server on the test host. +- id: tmux-context-mock-llm + name: './tmux-context-mock-llm.ts' + +- id: bash + name: './tmux-context-mock-bash.ts' + +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: tmux-context-mock + model: tmux-context-mock + persona: 'Test the tmux-context plugin.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..1136dad4a6 100644 --- a/examples/package.json +++ b/examples/package.json @@ -10,6 +10,7 @@ "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-bash": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", @@ -54,6 +55,7 @@ "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-tmux-context": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", diff --git a/knip.json b/knip.json index d010bcf21d..5075a3999b 100644 --- a/knip.json +++ b/knip.json @@ -36,6 +36,9 @@ "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", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", @@ -163,6 +166,16 @@ "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", diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index 7de339cd9a..44eba9a820 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: a5244dfe99a714605744b57d33f97359d4d6fa4e -README.zh.md: 2c1bdd6e5b290a771094719daa6e4d7c3bf577db +# pnpm run verify-translation-pairing --write packages/context/README.md +README.md: fce6e21816d261171aaeaa217171580adb7c43f9 +README.zh.md: d44a80479618ba0f83c05f7afb17358c7c210eaa diff --git a/packages/context/README.md b/packages/context/README.md index a5244dfe99..fce6e21816 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -2,12 +2,13 @@ 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` is 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` and `tmux-context` are 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/step`, reads `ctx.bash`) | | `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `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. diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index 2c1bdd6e5b..d44a804796 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -2,12 +2,13 @@ [English](README.md) | 中文 -这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context` 需要选择启用,标准 TUI 组合包则会显式组合 `session-reference`。 +这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context` 与 `tmux-context` 需要选择启用,标准 TUI 组合包则会显式组合 `session-reference`。 | 包 | 职责 | ctx key | |---|---|---| | `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` | | `time-context/` | 持久的逐步骤当前时间与耗时上下文 | (无) | +| `tmux-context/` | 持久的逐轮上下文,记录本 agent 所在的 tmux pane/window 位置 | (监听 `agent/step`,读取 `ctx.bash`) | | `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute`) | [`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了它的逐 agent/会话隔离与生命周期拆分。 diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml new file mode 100644 index 0000000000..ec4562eea4 --- /dev/null +++ b/packages/context/tmux-context/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md +README.md: 5ea36948d6d83135c5aa97650c0d77e942adbbaa +README.zh.md: 914d8d7c99c37de2c64541bcf4968996d819077d diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md new file mode 100644 index 0000000000..5ea36948d6 --- /dev/null +++ b/packages/context/tmux-context/README.md @@ -0,0 +1,68 @@ +# @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 | 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 '' +``` + +`$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. `` 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 ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, 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. diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md new file mode 100644 index 0000000000..914d8d7c99 --- /dev/null +++ b/packages/context/tmux-context/README.zh.md @@ -0,0 +1,68 @@ +# @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 | 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 '' +``` + +仅凭 `$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 状态发生变化的每一轮,注入一条带来源标记、含以下三行的上下文消息。`` 是 tmux 紧凑的 pane 树描述;pane 与 window 的像素尺寸有意省略,相邻 pane 的内容从不采集。 + +##### 变化轮次读数 + +```markdown +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, 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}` 不可用的环境中,该检查即为空操作。 diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json new file mode 100644 index 0000000000..a874ebb08c --- /dev/null +++ b/packages/context/tmux-context/package.json @@ -0,0 +1,49 @@ +{ + "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" + } +} diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts new file mode 100644 index 0000000000..495ab886a0 --- /dev/null +++ b/packages/context/tmux-context/src/index.ts @@ -0,0 +1,227 @@ +/** + * 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 = 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 { + 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 => { + 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 }) +} diff --git a/packages/context/tmux-context/src/invariant.ts b/packages/context/tmux-context/src/invariant.ts new file mode 100644 index 0000000000..181f1a2289 --- /dev/null +++ b/packages/context/tmux-context/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 */ diff --git a/packages/context/tmux-context/tests/tmux-context.e2e.ts b/packages/context/tmux-context/tests/tmux-context.e2e.ts new file mode 100644 index 0000000000..06e3c1f3f5 --- /dev/null +++ b/packages/context/tmux-context/tests/tmux-context.e2e.ts @@ -0,0 +1,77 @@ +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 { + 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) +}) diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts new file mode 100644 index 0000000000..ca1e49e07c --- /dev/null +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -0,0 +1,365 @@ +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 { + 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 { + 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 { + 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}`) + expect(command).toContain('#{pane_tty}') + 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/, + ) + }) +}) diff --git a/packages/context/tmux-context/tsconfig.json b/packages/context/tmux-context/tsconfig.json new file mode 100644 index 0000000000..fe893f9402 --- /dev/null +++ b/packages/context/tmux-context/tsconfig.json @@ -0,0 +1,40 @@ +{ + "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" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5309a6838..feb349534b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -412,6 +412,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../packages/ui/app-boot + '@deepseek-ai/dsh-bash': + specifier: workspace:* + version: link:../packages/bash/bash '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local @@ -544,6 +547,9 @@ 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 @@ -1690,6 +1696,37 @@ 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: diff --git a/tsconfig.host.json b/tsconfig.host.json index 0b4a30f0b6..58cd58f28c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -80,6 +80,7 @@ { "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" }, From 936d7e4ecb8d9f51f6c2c3189f18cd4624314bf3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 10:02:02 +0800 Subject: [PATCH 2/8] chore: remove headless-agent tmux-context test fixtures Out of scope and unnecessary for the tmux-context PR. The package itself (packages/context/tmux-context) and its own tests remain. --- .../tests/fixtures/tmux-context-driver.ts | 16 ------- .../tests/fixtures/tmux-context-mock-bash.ts | 46 ------------------- .../tests/fixtures/tmux-context-mock-llm.ts | 22 --------- .../tests/fixtures/tmux-context.cordis.yml | 21 --------- examples/package.json | 1 - knip.json | 3 -- 6 files changed, 109 deletions(-) delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context-driver.ts delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context.cordis.yml diff --git a/examples/headless-agent/tests/fixtures/tmux-context-driver.ts b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts deleted file mode 100644 index 2fd6d0f5ec..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context-driver.ts +++ /dev/null @@ -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() -} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts deleted file mode 100644 index 3e9540abff..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts +++ /dev/null @@ -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 { - const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t') - return Promise.resolve({ - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: 60_000, - stdout: { text: `${line}\n`, truncated: false }, - stderr: { text: '', truncated: false }, - }) - } - - override start(): BashProcess { - throw new Error('tmux-context must never start a background task') - } -} - -export const name = 'tmux-context-mock-bash' - -/** Register the deterministic `ctx.bash` executor for the fixture. */ -export function apply(ctx: Context): void { - ctx.plugin(TmuxMockBash) -} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts deleted file mode 100644 index 2f6c7a6408..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts +++ /dev/null @@ -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 { - const text = 'tmux context sampled' - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text } - yield { type: 'block-end', index: 0, block: { type: 'text', text } } - yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } - yield { type: 'finish', reason: { kind: 'stop' } } - } -} - -export const name = 'tmux-context-mock-llm' -export const inject = ['llm'] - -/** Register the test-only `tmux-context-mock` adapter. */ -export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter()) -} diff --git a/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml deleted file mode 100644 index 419922a0e3..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml +++ /dev/null @@ -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 diff --git a/examples/package.json b/examples/package.json index 1136dad4a6..f35acb0405 100644 --- a/examples/package.json +++ b/examples/package.json @@ -55,7 +55,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-cordis": "workspace:*", diff --git a/knip.json b/knip.json index 5075a3999b..306ebf46cf 100644 --- a/knip.json +++ b/knip.json @@ -36,9 +36,6 @@ "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", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", From b4aebc9b569c2d98ecfe1788c56a2560de205bbc Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 10:12:22 +0800 Subject: [PATCH 3/8] chore: update pnpm-lock.yaml after removing dsh-tmux-context from examples deps --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index feb349534b..668240f5c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -547,9 +547,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 From 7c99ff73317f482fa851016f6c1c71250b4421c3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 10:21:48 +0800 Subject: [PATCH 4/8] fix: relocate tmux-context e2e fixtures into package tests The headless-agent test fixtures were removed; move the driver, cordis.yml, and mocks into packages/context/tmux-context/tests/fixtures/ so the e2e test is self-contained. --- .../tests/fixtures/tmux-context-driver.ts | 17 +++++++ .../tests/fixtures/tmux-context-mock-bash.ts | 46 +++++++++++++++++++ .../tests/fixtures/tmux-context-mock-llm.ts | 22 +++++++++ .../tests/fixtures/tmux-context.cordis.yml | 21 +++++++++ .../tmux-context/tests/tmux-context.e2e.ts | 12 +---- 5 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 packages/context/tmux-context/tests/fixtures/tmux-context-driver.ts create mode 100644 packages/context/tmux-context/tests/fixtures/tmux-context-mock-bash.ts create mode 100644 packages/context/tmux-context/tests/fixtures/tmux-context-mock-llm.ts create mode 100644 packages/context/tmux-context/tests/fixtures/tmux-context.cordis.yml diff --git a/packages/context/tmux-context/tests/fixtures/tmux-context-driver.ts b/packages/context/tmux-context/tests/fixtures/tmux-context-driver.ts new file mode 100644 index 0000000000..45a8aa147a --- /dev/null +++ b/packages/context/tmux-context/tests/fixtures/tmux-context-driver.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env node +/** Test driver that sends two turns through one Headless Loader composition. */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('tmux-context driver requires a config path') + +const ctx = await boot('tmux-context-e2e', resolveConfigPath(configPath, undefined)) +try { + await runOneShot(ctx, { task: 'first' }) + await runOneShot(ctx, { task: 'second' }) +} finally { + await ctx.fiber.dispose() +} diff --git a/packages/context/tmux-context/tests/fixtures/tmux-context-mock-bash.ts b/packages/context/tmux-context/tests/fixtures/tmux-context-mock-bash.ts new file mode 100644 index 0000000000..3e9540abff --- /dev/null +++ b/packages/context/tmux-context/tests/fixtures/tmux-context-mock-bash.ts @@ -0,0 +1,46 @@ +import type { Context } from 'cordis' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' + +/** + * Deterministic `ctx.bash` for the tmux-context Loader fixture: any command + * (the plugin's `tmux display-message`) returns a fixed tab-delimited reading, + * so the injected tmux location is stable without a real tmux server. `start()` + * throws — tmux-context must never spawn a background process. + */ +class TmuxMockBash extends BashExecutor { + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + sandboxPolicy: request.sandboxPolicy, + } + } + + override run(_spec: BashExecSpec): Promise { + const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t') + return Promise.resolve({ + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: `${line}\n`, truncated: false }, + stderr: { text: '', truncated: false }, + }) + } + + override start(): BashProcess { + throw new Error('tmux-context must never start a background task') + } +} + +export const name = 'tmux-context-mock-bash' + +/** Register the deterministic `ctx.bash` executor for the fixture. */ +export function apply(ctx: Context): void { + ctx.plugin(TmuxMockBash) +} diff --git a/packages/context/tmux-context/tests/fixtures/tmux-context-mock-llm.ts b/packages/context/tmux-context/tests/fixtures/tmux-context-mock-llm.ts new file mode 100644 index 0000000000..2f6c7a6408 --- /dev/null +++ b/packages/context/tmux-context/tests/fixtures/tmux-context-mock-llm.ts @@ -0,0 +1,22 @@ +import type { Context } from 'cordis' +import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' + +/** Deterministic one-step adapter for the tmux-context Loader fixture. */ +class TmuxContextMockAdapter extends LlmAdapter { + async * stream(): AsyncIterable { + const text = 'tmux context sampled' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'tmux-context-mock-llm' +export const inject = ['llm'] + +/** Register the test-only `tmux-context-mock` adapter. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter()) +} diff --git a/packages/context/tmux-context/tests/fixtures/tmux-context.cordis.yml b/packages/context/tmux-context/tests/fixtures/tmux-context.cordis.yml new file mode 100644 index 0000000000..419922a0e3 --- /dev/null +++ b/packages/context/tmux-context/tests/fixtures/tmux-context.cordis.yml @@ -0,0 +1,21 @@ +# Test-only composition: keep tmux-context opt-in while exercising its real Loader/app path. +# A deterministic mock ctx.bash returns a fixed tmux reading, so the injected location +# is stable without a real tmux server on the test host. +- id: tmux-context-mock-llm + name: './tmux-context-mock-llm.ts' + +- id: bash + name: './tmux-context-mock-bash.ts' + +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: tmux-context-mock + model: tmux-context-mock + persona: 'Test the tmux-context plugin.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/packages/context/tmux-context/tests/tmux-context.e2e.ts b/packages/context/tmux-context/tests/tmux-context.e2e.ts index 06e3c1f3f5..386a326624 100644 --- a/packages/context/tmux-context/tests/tmux-context.e2e.ts +++ b/packages/context/tmux-context/tests/tmux-context.e2e.ts @@ -5,16 +5,8 @@ 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 driver = fileURLToPath(new URL('./fixtures/tmux-context-driver.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('./fixtures/tmux-context.cordis.yml', import.meta.url)) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) async function jsonlFiles(dir: string): Promise { From 8060f7fc0d5093d2746d9b1c6d495fda8d7c848a Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 10:28:33 +0800 Subject: [PATCH 5/8] chore: remove tmux-context e2e test and headless-agent fixtures The headless-agent test fixtures and the e2e test that depended on them are out of scope for this PR. Unit tests in tmux-context.spec.ts cover the plugin behavior. --- .../tests/fixtures/tmux-context-driver.ts | 17 ----- .../tests/fixtures/tmux-context-mock-bash.ts | 46 ------------- .../tests/fixtures/tmux-context-mock-llm.ts | 22 ------ .../tests/fixtures/tmux-context.cordis.yml | 21 ------ .../tmux-context/tests/tmux-context.e2e.ts | 69 ------------------- 5 files changed, 175 deletions(-) delete mode 100644 packages/context/tmux-context/tests/fixtures/tmux-context-driver.ts delete mode 100644 packages/context/tmux-context/tests/fixtures/tmux-context-mock-bash.ts delete mode 100644 packages/context/tmux-context/tests/fixtures/tmux-context-mock-llm.ts delete mode 100644 packages/context/tmux-context/tests/fixtures/tmux-context.cordis.yml delete mode 100644 packages/context/tmux-context/tests/tmux-context.e2e.ts diff --git a/packages/context/tmux-context/tests/fixtures/tmux-context-driver.ts b/packages/context/tmux-context/tests/fixtures/tmux-context-driver.ts deleted file mode 100644 index 45a8aa147a..0000000000 --- a/packages/context/tmux-context/tests/fixtures/tmux-context-driver.ts +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env node -/** Test driver that sends two turns through one Headless Loader composition. */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ - -import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' - -const configPath = process.argv[2] -if (configPath === undefined) throw new Error('tmux-context driver requires a config path') - -const ctx = await boot('tmux-context-e2e', resolveConfigPath(configPath, undefined)) -try { - await runOneShot(ctx, { task: 'first' }) - await runOneShot(ctx, { task: 'second' }) -} finally { - await ctx.fiber.dispose() -} diff --git a/packages/context/tmux-context/tests/fixtures/tmux-context-mock-bash.ts b/packages/context/tmux-context/tests/fixtures/tmux-context-mock-bash.ts deleted file mode 100644 index 3e9540abff..0000000000 --- a/packages/context/tmux-context/tests/fixtures/tmux-context-mock-bash.ts +++ /dev/null @@ -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 { - const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t') - return Promise.resolve({ - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: 60_000, - stdout: { text: `${line}\n`, truncated: false }, - stderr: { text: '', truncated: false }, - }) - } - - override start(): BashProcess { - throw new Error('tmux-context must never start a background task') - } -} - -export const name = 'tmux-context-mock-bash' - -/** Register the deterministic `ctx.bash` executor for the fixture. */ -export function apply(ctx: Context): void { - ctx.plugin(TmuxMockBash) -} diff --git a/packages/context/tmux-context/tests/fixtures/tmux-context-mock-llm.ts b/packages/context/tmux-context/tests/fixtures/tmux-context-mock-llm.ts deleted file mode 100644 index 2f6c7a6408..0000000000 --- a/packages/context/tmux-context/tests/fixtures/tmux-context-mock-llm.ts +++ /dev/null @@ -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 { - const text = 'tmux context sampled' - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text } - yield { type: 'block-end', index: 0, block: { type: 'text', text } } - yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } - yield { type: 'finish', reason: { kind: 'stop' } } - } -} - -export const name = 'tmux-context-mock-llm' -export const inject = ['llm'] - -/** Register the test-only `tmux-context-mock` adapter. */ -export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter()) -} diff --git a/packages/context/tmux-context/tests/fixtures/tmux-context.cordis.yml b/packages/context/tmux-context/tests/fixtures/tmux-context.cordis.yml deleted file mode 100644 index 419922a0e3..0000000000 --- a/packages/context/tmux-context/tests/fixtures/tmux-context.cordis.yml +++ /dev/null @@ -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 diff --git a/packages/context/tmux-context/tests/tmux-context.e2e.ts b/packages/context/tmux-context/tests/tmux-context.e2e.ts deleted file mode 100644 index 386a326624..0000000000 --- a/packages/context/tmux-context/tests/tmux-context.e2e.ts +++ /dev/null @@ -1,69 +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' - -const driver = fileURLToPath(new URL('./fixtures/tmux-context-driver.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('./fixtures/tmux-context.cordis.yml', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) - -async function jsonlFiles(dir: string): Promise { - 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) -}) From f15ff737cd4d4e9241e0ca7c374f813988db4f43 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 10:37:17 +0800 Subject: [PATCH 6/8] chore: drop e2e-only knip entry, loader-smoke dep, and tsconfig reference Follows removing the tmux-context e2e test: the .e2e.ts knip entry pattern matched nothing and dsh-loader-smoke became an unused devDependency. --- knip.json | 3 +-- packages/context/tmux-context/package.json | 1 - packages/context/tmux-context/tsconfig.json | 3 --- pnpm-lock.yaml | 3 --- 4 files changed, 1 insertion(+), 9 deletions(-) diff --git a/knip.json b/knip.json index 306ebf46cf..c02e997c0f 100644 --- a/knip.json +++ b/knip.json @@ -165,8 +165,7 @@ }, "packages/context/tmux-context": { "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.spec.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index a874ebb08c..222cdfa55b 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -41,7 +41,6 @@ "@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" diff --git a/packages/context/tmux-context/tsconfig.json b/packages/context/tmux-context/tsconfig.json index fe893f9402..a5983a2c7f 100644 --- a/packages/context/tmux-context/tsconfig.json +++ b/packages/context/tmux-context/tsconfig.json @@ -27,9 +27,6 @@ { "path": "../../core/system-prompt" }, - { - "path": "../../support/loader-smoke" - }, { "path": "../../support/invariants" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 668240f5c5..356a566630 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1711,9 +1711,6 @@ importers: '@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 From 44a657c1495f25ba1384fe27c6e6094e0317b53b Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 11:11:17 +0800 Subject: [PATCH 7/8] fix(tmux-context): contain executor rejection as a warning, correct suppression claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #758. bash.run() only promises to resolve for nonzero exits, timeouts, and aborts, and bash.resolve() can reject on policy grounds, so either could escape the serial agent/step listener and abort the model turn — contradicting the plugin's documented failed-query no-op contract. Contain both and log a warning instead; the location is optional context. The Agent Note claimed an unchanged location suppresses the query. It does not: only the interval floor is checked before the query, while change suppression compares state the query returned. Corrected in both languages and re-recorded the i18n pairs. --- ...2026-07-27-tmux-location-context.i18n.yaml | 4 +- .../2026-07-27-tmux-location-context.md | 4 +- .../2026-07-27-tmux-location-context.zh.md | 4 +- docs/config-catalog.md | 2 +- .../context/tmux-context/README.i18n.yaml | 4 +- packages/context/tmux-context/README.md | 2 +- packages/context/tmux-context/README.zh.md | 2 +- packages/context/tmux-context/src/index.ts | 26 +++++++++--- .../tmux-context/tests/tmux-context.spec.ts | 42 +++++++++++++++++++ 9 files changed, 73 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml index 29817b7a69..b5fa2609a4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md -2026-07-27-tmux-location-context.md: 9436e9bcf764a505a4da3c8f5ef1d6313ba648d3 -2026-07-27-tmux-location-context.zh.md: 09cbf056a577918bc8f9682843154b8dc1bda5a0 +2026-07-27-tmux-location-context.md: bac5861f7f55c259de04d153115f164d90c415ad +2026-07-27-tmux-location-context.zh.md: 03cd722381c45604f7aae8f3d0a9f9fbb8b12bb5 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md index 9436e9bcf7..bac5861f7f 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md @@ -42,11 +42,11 @@ The published `./invariant` companion registers no runtime check: a reading is a ## 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 `tmux display-message` subprocess (through the sandboxed bash seam) on the first step of each turn that is due; unchanged locations and the optional interval floor suppress both the query and the injection. +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 `tmux display-message` subprocess (through the sandboxed bash seam) on the first step of each turn that is due. The optional interval floor is checked before the query and so suppresses both; an unchanged location is detected only by comparing the returned state, so it suppresses the injection while still paying for the query. ## 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%. +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, and a contained executor rejection from either `resolve()` or `run()` that warns instead of failing the turn); 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 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md index 09cbf056a5..03cd722381 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md @@ -42,11 +42,11 @@ window active=<0|1>, pane active=<0|1>, layout ## 后果 -启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 `tmux display-message` 子进程(经沙箱化的 bash seam);位置未变化以及可选的间隔下限会同时抑制查询与注入。 +启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 `tmux display-message` 子进程(经沙箱化的 bash seam)。可选的间隔下限在查询之前检查,因此同时抑制查询与注入;而位置未变化只能通过比较查询返回的状态得知,因此它只抑制注入,查询开销仍会付出。 ## 测试 -单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 +单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消,以及 `resolve()` 或 `run()` 抛出的执行器拒绝被兜住并记录警告而非使该轮失败);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 ## 考虑过的替代方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 95bfde61a5..10240c89fd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1499,7 +1499,7 @@ export interface Config { } ``` -Source: [`packages/context/tmux-context/src/index.ts:33`](../packages/context/tmux-context/src/index.ts) +Source: [`packages/context/tmux-context/src/index.ts:34`](../packages/context/tmux-context/src/index.ts) ## `@deepseek-ai/dsh-token-meter` diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml index ec4562eea4..9a6f113cc8 100644 --- a/packages/context/tmux-context/README.i18n.yaml +++ b/packages/context/tmux-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md -README.md: 5ea36948d6d83135c5aa97650c0d77e942adbbaa -README.zh.md: 914d8d7c99c37de2c64541bcf4968996d819077d +README.md: a166a46d20f472cb5d8f045e2456ce3e6de7a2f2 +README.zh.md: 0575d549e352239e7d954870eaf40beea1169cc6 diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md index 5ea36948d6..a166a46d20 100644 --- a/packages/context/tmux-context/README.md +++ b/packages/context/tmux-context/README.md @@ -27,7 +27,7 @@ pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 exec tmux display-message -t "$TMUX_PANE" -p '' ``` -`$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. +`$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. The location is optional, so an executor rejection — a policy refusal from `resolve()` or an infrastructure failure from `run()` — is contained and logged as a warning rather than failing the turn. 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. diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md index 914d8d7c99..0575d549e3 100644 --- a/packages/context/tmux-context/README.zh.md +++ b/packages/context/tmux-context/README.zh.md @@ -27,7 +27,7 @@ pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 exec tmux display-message -t "$TMUX_PANE" -p '' ``` -仅凭 `$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 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。 +仅凭 `$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 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。由于位置信息是可选的,执行器的拒绝——`resolve()` 的策略拒绝或 `run()` 的基础设施故障——会被兜住并记录为警告,而不会使该轮失败。 状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。 diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index 495ab886a0..35f4c4a22f 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -12,15 +12,16 @@ * 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. + * absent `ctx.bash`, or a failed query is a no-op, never an error: an executor + * rejection is contained and logged as a warning so the turn continues. * * @module @deepseek-ai/dsh-tmux-context */ -import type { Context } from 'cordis' +import type { Context, LoggerService } from 'cordis' import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash' import { createUserMessage } from '@deepseek-ai/dsh-llm' /** Cordis plugin name used by loader diagnostics. */ @@ -92,13 +93,20 @@ const FIELD_SEP = '\\t' * on a match, so an inherited environment reads as "not in tmux" and injects * nothing. * + * The location is optional context, so an executor rejection is a failed query, + * not a turn failure: `resolve()` may reject the command on policy grounds and + * `run()` only promises to resolve for nonzero exits, timeouts, and aborts, so + * both are contained and reported as a warning. + * * @param bash - the executor seam used to run the read-only tmux/ps commands. + * @param logger - receives a warning when the executor rejects the query. * @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, + logger: LoggerService, processId: number, signal: AbortSignal, ): Promise { @@ -111,8 +119,14 @@ async function queryTmuxLocation( '[ "$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) + let result: BashRunResult + try { + result = await bash.run(bash.resolve({ command, signal })) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + logger.warn(`tmux location query failed: ${message}; injecting no location this turn`) + return undefined + } if (result.exitCode !== 0) return undefined const line = result.stdout.text.split('\n', 1)[0] as string const parts = line.split(FIELD_SEP) @@ -215,7 +229,7 @@ export function apply(ctx: Context, config: Config): void { const now = Date.now() if (now >= previous.time && now - previous.time < refreshIntervalMs) return } - const location = await queryTmuxLocation(bash, process.pid, signal) + const location = await queryTmuxLocation(bash, ctx.logger, process.pid, signal) if (location === undefined) return const state = renderState(location) if (previous !== undefined && previous.state === state) return diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index ca1e49e07c..fa162f8d3f 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -51,8 +51,10 @@ class FakeBash extends BashExecutor { commands: string[] = [] result: BashRunResult = runResult(`${tmuxLine()}\n`) runError?: Error + resolveError?: Error override resolve(request: BashExecRequest): BashExecSpec { + if (this.resolveError) throw this.resolveError return { command: request.command, workdir: request.workdir ?? '/work', @@ -325,6 +327,46 @@ describe('tmux-context no-op paths', () => { expect(contextTexts(session)).toHaveLength(0) }) + it('warns and injects nothing when the executor rejects the run', async () => { + const { ctx, bash } = await mount({}, true) + bash.runError = new Error('bash executor unavailable') + const warn = vi.spyOn(ctx.logger, 'warn') + const session = new Session(SessionId('run-rejected')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('bash executor unavailable')) + }) + + it('warns and injects nothing when the executor rejects the command at resolve', async () => { + const { ctx, bash } = await mount({}, true) + bash.resolveError = new Error('command denied by policy') + const warn = vi.spyOn(ctx.logger, 'warn') + const session = new Session(SessionId('resolve-rejected')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('command denied by policy')) + }) + + it('reports a non-Error rejection in the warning', async () => { + const { ctx, bash } = await mount({}, true) + // Non-Error throw: the executor seam is typed, but a bad impl can reject with anything. + bash.runError = 'spawn refused' as unknown as Error + const warn = vi.spyOn(ctx.logger, 'warn') + const session = new Session(SessionId('non-error-rejection')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('spawn refused')) + }) + 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')) From 31189cf403e87a35896ce457283c13472fb5a957 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 12:52:14 +0800 Subject: [PATCH 8/8] chore: drop the examples dsh-bash dep left by the removed mock-bash fixture Nothing under examples/ references @deepseek-ai/dsh-bash now that the tmux-context fixtures are gone; the examples workspace ignores every @deepseek-ai/* dependency in knip, so no gate could catch it. This leaves examples/package.json untouched by the PR. --- examples/package.json | 1 - pnpm-lock.yaml | 3 --- 2 files changed, 4 deletions(-) diff --git a/examples/package.json b/examples/package.json index f35acb0405..51fc48b8fa 100644 --- a/examples/package.json +++ b/examples/package.json @@ -10,7 +10,6 @@ "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", - "@deepseek-ai/dsh-bash": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 356a566630..4fcf7122ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -412,9 +412,6 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../packages/ui/app-boot - '@deepseek-ai/dsh-bash': - specifier: workspace:* - version: link:../packages/bash/bash '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local