DSH / Atlas
2026-07-27implementedfeature

tmux-location context

tmux 位置上下文

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

English

Problem

An agent running inside tmux has no way to tell the model where it is: which session, window, and pane the process occupies, and how the window is laid out. A user directing several panes wants the model to orient itself to its own location so instructions like "the pane below" or "this window" resolve. The location must reach the model as durable, reconstructable context, not a system-prompt value rewritten in place, and must cost nothing when the location has not changed.

tmux exposes this without a daemon: $TMUX_PANE names the process's pane, and tmux display-message -t "$TMUX_PANE" -p '<format>' prints any pane/window/session field. The open question was how to observe it — pull on each preparation, or push from a tmux hook — and how to avoid a per-step token cost and hidden process-local state.

Decision

@deepseek-ai/dsh-tmux-context is an opt-in function plugin in packages/context/tmux-context/, alongside the other bounded request-context enrichments that define neither a tool nor a service. The shipped TUI mounts it because terminal-multiplexer context is specific to that surface; dsh-agent-spine-demo and the Web/headless surfaces stay silent.

Pull on the first step of each turn, not a tmux push. The plugin prepends an agent/pre-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.shell seam, never raw child_process. The listener runs the tmux/ps read commands through ctx.shell, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent ctx.shell, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how agent-instructions no-ops without an fs provider.

Detect a real pane by tty, not by $TMUX_PANE alone. $TMUX_PANE is inherited: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) carries $TMUX/$TMUX_PANE from that ancestor even though the process does not live in that pane, which otherwise injects a stale, wrong location. The command resolves this process's controlling terminal with ps -o tty= -p <pid> (the agent's own pid, passed in-process) and compares it to the pane's #{pane_tty}; fields are emitted only on a match. A genuine pane owns this process's tty; an inherited environment names some other pane's tty and reads as "not in tmux". Checking $TMUX instead does not help — it is inherited identically. This is the definitive discriminator and needs no allowlist of terminal emulators.

Own location and layout only. The queried fields are session name, window index/name, pane index/id, window/pane active flags, and window_layout. Pane and window pixel sizes are excluded (layout tree conveys structure; sizes are noisy and change on every terminal resize). Sibling-pane contents are never captured (capture-pane), keeping the reading small and avoiding scraping unrelated, possibly sensitive, output.

Inject only on change, with optional interval floor. When due, the plugin calls agent.inject() for one user/message with source { kind: 'plugin', plugin: 'tmux-context' }. Change suppression compares the rendered state block (everything after the turn preamble line) against the latest injection of this source, found by scanning raw durable session events — so the schedule survives compaction and process resume without a process-local cache. The optional refreshIntervalMs (manually validated as a non-negative safe integer at plugin load) additionally suppresses injections within that window of the latest one.

Text

tmux location (turn <turn>):
session <session>, window <index> "<name>", pane <index> <pane-id>
window active=<0|1>, pane active=<0|1>, layout <window-layout>

The turn preamble is the volatile first line; the two-line state block below it is the unit compared for change suppression, so re-injection is driven by tmux state, not loop position.

Durability and request reconstruction

Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and request/header carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later agent/pre-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. The shipped TUI opts in; custom deployments may compose the plugin directly. Outside a real tmux pane — including a terminal that merely inherited $TMUX/$TMUX_PANE — or without a ctx.shell 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, and a contained executor rejection from either resolve() or run() that warns instead of failing the turn); prepended ordering before ordinary agent/pre-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 path and hand-rolls subprocess code the ctx.shell 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.

中文

问题

运行在 tmux 内的 agent(智能体)无法告诉模型自己身在何处:进程占据哪个 session、window、pane,以及 window 如何布局。当用户操作多个 pane 时,希望模型能对自身位置有所定位,从而让「下方的 pane」「这个 window」之类的指令得以解析。位置必须以持久、可重建的上下文形式送达模型,而非在原地被改写的系统提示值,并且当位置未变化时不产生任何成本。

tmux 无需守护进程即可暴露这些信息:$TMUX_PANE 标识进程所在 pane,tmux display-message -t "$TMUX_PANE" -p '<format>' 可打印任意 pane/window/session 字段。待决问题在于如何观测——在每次准备时拉取,还是由 tmux hook 推送——以及如何避免逐步骤 token 成本与隐藏的进程内状态。

决策

@deepseek-ai/dsh-tmux-context 是位于 packages/context/tmux-context/ 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。已交付的 TUI 会挂载它,因为终端复用器上下文是该界面特有的;dsh-agent-spine-demo 与 Web/无头界面保持沉默。

在每轮的第一个步骤拉取,而非 tmux 推送。 插件前置注册一个 agent/pre-step 监听器,仅在 step === 1 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个步骤为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。

通过 ctx.shell seam 读取,绝不用裸 child_process 监听器通过 ctx.shell 运行 tmux/ps 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。ctx.shell 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 agent-instructions 在无 fs 提供方时的空操作一致。

以 tty 判定真实 pane,而非仅凭 $TMUX_PANE $TMUX_PANE 会被继承:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程带上 $TMUX$TMUX_PANE,即使进程并不位于那个 pane 中,否则就会注入一个陈旧且错误的位置。命令用 ps -o tty= -p <pid>(在进程内传入 agent 自身的 pid)解析本进程的控制终端,并与 pane 的 #{pane_tty} 比较;只有匹配时才输出字段。真正的 pane 拥有本进程的 tty;继承而来的环境指向的是另一个 pane 的 tty,因而被读作「不在 tmux 中」。改为检查 $TMUX 也无济于事——它同样会被继承。这是决定性的判别依据,且无需维护终端模拟器名单。

仅自身位置与布局。 查询字段为 session name、window index/name、pane index/id、window/pane 活动标志以及 window_layout。省略 pane 与 window 像素尺寸(布局树已传达结构;尺寸嘈杂且每次终端缩放都会变化)。从不采集相邻 pane 内容(capture-pane),使读数保持小巧,并避免抓取无关、可能敏感的输出。

仅在变化时注入,并可选间隔下限。 需要时,插件调用 agent.inject() 注入一条来源为 { kind: 'plugin', plugin: 'tmux-context' }user/message。变化抑制将渲染出的状态块(轮次前缀行之后的全部内容)与该来源的最近一次注入比较,后者通过扫描原始持久会话事件获得——因此调度可跨压缩(compaction)与进程恢复存续,无需进程内缓存。可选的 refreshIntervalMs(在插件加载时手动校验为非负安全整数)会额外抑制距最近一次注入不足该窗口的注入。

文本

tmux location (turn <turn>):
session <session>, window <index> "<name>", pane <index> <pane-id>
window active=<0|1>, pane active=<0|1>, layout <window-layout>

轮次前缀是易变的首行;其下的两行状态块才是变化抑制所比较的单元,因此重新注入由 tmux 状态驱动,而非循环位置。

持久性与请求重建

每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,request/header 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的步骤:由于前置监听器最先运行,当后续 agent/pre-step 监听器取消该尝试或导致其失败时其追加可能仍会保留,只追加的日志不做回滚。

发布的 ./invariant 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的流水线测试固定。

后果

启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。已交付的 TUI 选择启用;自定义部署可直接组合该插件。在真实 tmux pane 之外——包括仅继承了 $TMUX$TMUX_PANE 的终端——或没有 ctx.shell 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 user/message,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个位置发生变化的轮次至多增加一条两行消息。拉取模型在每个到期轮次的第一个步骤增加一次 tmux display-message 子进程(经沙箱化的 bash seam)。可选的间隔下限在查询之前检查,因此同时抑制查询与注入;而位置未变化只能通过比较查询返回的状态得知,因此它只抑制注入,查询开销仍会付出。

测试

单元测试固定了:首个步骤的注入及来源/表层元数据;以 $TMUX_PANE 为键的命令(含其 #{pane_tty}ps -o tty= 的比对守卫);步骤门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消,以及 resolve()run() 抛出的执行器拒绝被兜住并记录警告而非使该轮失败);前置排序先于普通 agent/pre-step 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。

考虑过的替代方案

  • 由 tmux hook/后台监视器推送——否决:需要在用户的 tmux 中安装 hook,并引入带清理的后台进程,只为换取按轮次上下文并不需要的步内新鲜度。
  • 每个步骤都运行——否决:位置在一轮内稳定;重复查询只增加 token 成本而无新信息。以 step === 1 为门槛得到按轮次读数。
  • child_process——否决:绕过沙箱策略路径,并手写 ctx.shell 执行器已拥有的子进程代码。
  • 包含 pane/window 像素尺寸——否决:尺寸每次缩放都变动、徒增噪声;布局树已传达结构。
  • capture-pane 抓取相邻 pane——否决:庞大、嘈杂且涉及隐私;超出「自身位置」范围。
  • 动态系统提示区块——否决:替换某个值会抹去支撑先前推理的历史读数且不可重建;单条持久且带来源的消息在每个位置变得可见时予以记录。
  • 信任 $TMUX_PANE(或 $TMUX)存在即可——否决:两者都会被从 tmux shell 启动的终端(VS Code 集成终端)继承,于是非 pane 进程会注入陈旧位置。pane 的 #{pane_tty} 与本进程控制终端的比对才是决定性检查。
  • 对已知终端模拟器设黑名单(如 TERM_PROGRAM=vscode——否决:名单不完整且会不断增长,仍会漏掉其他启动器;tty 比对精确且与启动器无关。
  • 用运行时 invariant 校验每条读数的轮次、位置与格式——最初随包发布,随后移除:它从日志中重新推导生产者自身的调度,并对同一个包刚刚渲染出的文本断言正则,因此只是重述 apply(),而非检查一条独立关系。它能报出的每种失败都必须先修改本包,而这些本包的流水线测试已经覆盖。仅当出现插件自身并不计算的关系时才重新引入伴生检查——例如读数将来具备可被其他包破坏的跨轮次顺序或包裹义务。