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.
This commit is contained in:
Turtle
2026-07-28 21:29:24 +08:00
parent cad0615261
commit 1ac946f747
26 changed files with 1244 additions and 6 deletions

View File

@@ -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

View File

@@ -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 <pid> | tr -d ' ')
pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
[ "$pane_tty" = "/dev/$self_tty" ] || exit 1
exec tmux display-message -t "$TMUX_PANE" -p '<format>'
```
`$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.bash` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.bash` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error.
State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane is picked up without any tmux hook or background process. The plugin re-injects only when the rendered tmux state differs from its last injection, so an unchanged location adds nothing.
## Timing semantics
When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback).
## Model Experience
### Preparation-time tmux location
#### What the model sees
On each turn whose tmux state changed, one source-tagged context message with the three lines below. `<window-layout>` is tmux's compact pane-tree description; pane and window pixel sizes are intentionally excluded, and the contents of sibling panes are never captured.
##### Changed-turn reading
```markdown
tmux location (turn <turn>):
session <session>, window <index> "<name>", pane <index> <pane-id>
window active=<0|1>, pane active=<0|1>, layout <window-layout>
```
#### Token effect
Each two-line reading accumulates until compaction shadows it. Unchanged locations and interval suppression add nothing.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
## Known Limitations and Deferred Work
- **First step only** — a pane moved or resized mid-turn is reflected on the next turn, not between steps.
- **Own location only** — the plugin never captures the visible text of sibling panes.
- **Layout, not size** — pane/window pixel dimensions are omitted; only the layout tree and active flags are reported.
- **Tab-delimited fields** — a tmux window name containing the literal two-character sequence `\t` would mis-split the reading and be skipped as malformed; ordinary names are unaffected.
- **tty-based pane detection** — the process is considered "in tmux" only when its controlling terminal matches `$TMUX_PANE`'s `#{pane_tty}`. This deliberately excludes terminals that inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor (e.g. a VS Code integrated terminal). `ps -o tty=` is POSIX; the check is a no-op wherever it or `#{pane_tty}` is unavailable.

View File

@@ -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 <pid> | tr -d ' ')
pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
[ "$pane_tty" = "/dev/$self_tty" ] || exit 1
exec tmux display-message -t "$TMUX_PANE" -p '<format>'
```
仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX``$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.bash` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.bash` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。
状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。
## 时序语义
当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入的 `user/message`,来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step由于监听器最先运行当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)。
## 模型体验
### 准备期 tmux 位置
#### 模型看到的内容
在 tmux 状态发生变化的每一轮,注入一条带来源标记、含以下三行的上下文消息。`<window-layout>` 是 tmux 紧凑的 pane 树描述pane 与 window 的像素尺寸有意省略,相邻 pane 的内容从不采集。
##### 变化轮次读数
```markdown
tmux location (turn <turn>):
session <session>, window <index> "<name>", pane <index> <pane-id>
window active=<0|1>, pane active=<0|1>, layout <window-layout>
```
#### Token 影响
每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。
#### KV 缓存影响
只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV 缓存条目失效。
## 已知限制与后续工作
- **仅第一个 step**——轮次中途移动或缩放的 pane 会在下一轮反映,而非在 step 之间。
- **仅自身位置**——插件从不采集相邻 pane 的可见文本。
- **只有布局,没有尺寸**——省略 pane/window 像素尺寸;仅报告布局树与活动标志。
- **制表符分隔字段**——若 tmux window 名称包含字面两字符序列 `\t`,会使读数分割错误并作为非法读数跳过;常规名称不受影响。
- **基于 tty 的 pane 判定**——只有当进程的控制终端与 `$TMUX_PANE``#{pane_tty}` 一致时,才视为“位于 tmux 中”。这会有意排除从 tmux 祖先进程继承 `$TMUX``$TMUX_PANE` 的终端(如 VS Code 集成终端)。`ps -o tty=` 属于 POSIX在其或 `#{pane_tty}` 不可用的环境中,该检查即为空操作。

View File

@@ -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"
}
}

View File

@@ -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<Config> = z.object({
refreshIntervalMs: z.number(),
})
/**
* Tab-separated tmux format fields, in query order. Layout (`window_layout`)
* is the pane-tree description; pane/window pixel sizes are intentionally
* excluded (own location and layout only, per the package scope).
*/
const TMUX_FIELDS = [
'#{session_name}',
'#{window_index}',
'#{window_name}',
'#{pane_index}',
'#{pane_id}',
'#{window_active}',
'#{pane_active}',
'#{window_layout}',
] as const
/** Structured tmux location parsed from one `display-message` reading. */
interface TmuxLocation {
sessionName: string
windowIndex: string
windowName: string
paneIndex: string
paneId: string
windowActive: string
paneActive: string
windowLayout: string
}
/** Prefix marking the volatile turn/step preamble line of a rendered reading. */
const READING_PREFIX = 'tmux location (turn '
/**
* Field separator between tmux format fields. tmux does not interpret C escapes
* in a format, so the literal two-character sequence `\t` is emitted verbatim
* and split back out here; this avoids embedding raw whitespace in the command.
*/
const FIELD_SEP = '\\t'
/**
* Read this process's tmux location through the bash seam, or `undefined` when
* this process is not genuinely running inside a tmux pane or the query fails.
*
* `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell
* (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and
* `$TMUX_PANE` from that ancestor, so the variables are present even though this
* process does not live in that pane. The command therefore also compares the
* pane's `#{pane_tty}` against this process's own controlling terminal
* (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty,
* an inherited environment names some other pane's tty. Fields are emitted only
* on a match, so an inherited environment reads as "not in tmux" and injects
* nothing.
*
* @param bash - the executor seam used to run the read-only tmux/ps commands.
* @param processId - this agent process's pid, whose controlling tty must match the pane.
* @param signal - abort signal forwarded to the executor.
* @returns the parsed location, or `undefined` when not in a real pane or on any failure.
*/
async function queryTmuxLocation(
bash: BashExecutor,
processId: number,
signal: AbortSignal,
): Promise<TmuxLocation | undefined> {
const format = TMUX_FIELDS.join(FIELD_SEP)
const command = [
'[ -n "$TMUX_PANE" ] || exit 1',
`self_tty=$(ps -o tty= -p ${processId} | tr -d ' ')`,
'[ -n "$self_tty" ] || exit 1',
'pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1',
'[ "$pane_tty" = "/dev/$self_tty" ] || exit 1',
`exec tmux display-message -t "$TMUX_PANE" -p '${format}'`,
].join('\n')
const spec = bash.resolve({ command, signal })
const result = await bash.run(spec)
if (result.exitCode !== 0) return undefined
const line = result.stdout.text.split('\n', 1)[0] as string
const parts = line.split(FIELD_SEP)
if (parts.length !== TMUX_FIELDS.length) return undefined
const [
sessionName,
windowIndex,
windowName,
paneIndex,
paneId,
windowActive,
paneActive,
windowLayout,
] = parts as [string, string, string, string, string, string, string, string]
if (paneId.length === 0) return undefined
return {
sessionName,
windowIndex,
windowName,
paneIndex,
paneId,
windowActive,
paneActive,
windowLayout,
}
}
/**
* Render the stable tmux state block: the part of a reading compared for
* change suppression. It excludes the turn preamble so re-injection is driven
* only by tmux state, not by loop position.
*/
function renderState(location: TmuxLocation): string {
return `session ${location.sessionName}, `
+ `window ${location.windowIndex} ${JSON.stringify(location.windowName)}, `
+ `pane ${location.paneIndex} ${location.paneId}\n`
+ `window active=${location.windowActive}, pane active=${location.paneActive}, `
+ `layout ${location.windowLayout}`
}
/** Render the full durable reading, including the volatile turn preamble. */
function renderReading(location: TmuxLocation, turn: number): string {
return `${READING_PREFIX}${turn}):\n${renderState(location)}`
}
/**
* The stable state block of this plugin's latest durable injection, or
* `undefined` when the session has none. Scans raw durable events so the
* schedule survives compaction and resumed processes without process-local
* cache state.
*/
function latestInjectedState(agent: Agent): { state: string; time: number } | undefined {
for (const event of [...agent.session.events].reverse()) {
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === name) {
const [block] = event.data.content
if (block?.type !== 'text') return undefined
const newline = block.text.indexOf('\n')
const state = newline === -1 ? '' : block.text.slice(newline + 1)
return { state, time: event.time }
}
}
return undefined
}
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
if (refreshIntervalMs !== undefined && (
!Number.isSafeInteger(refreshIntervalMs)
|| refreshIntervalMs < 0
)) {
throw new TypeError(
`tmux-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
)
}
}
/**
* Register a prepended `agent/step` listener for the lifetime of `ctx`.
* @param ctx - plugin context; the listener is disposed with it.
* @param config - durable refresh scheduling configuration.
* @throws when the refresh interval is invalid.
*/
export function apply(ctx: Context, config: Config): void {
const refreshIntervalMs = config.refreshIntervalMs
validateRefreshInterval(refreshIntervalMs)
ctx.on('agent/step', async (
agent: Agent,
turn: number,
step: number,
signal: AbortSignal,
): Promise<void> => {
if (signal.aborted || step !== 1) return
const bash = ctx.get('bash')
if (bash === undefined) return
const previous = latestInjectedState(agent)
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) {
const now = Date.now()
if (now >= previous.time && now - previous.time < refreshIntervalMs) return
}
const location = await queryTmuxLocation(bash, process.pid, signal)
if (location === undefined) return
const state = renderState(location)
if (previous !== undefined && previous.state === state) return
agent.inject(createUserMessage({
content: [{ type: 'text', text: renderReading(location, turn) }],
source: { kind: 'plugin', plugin: name },
}))
}, { prepend: true })
}

View File

@@ -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 */

View File

@@ -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<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const paths = await Promise.all(entries.map(async (entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return jsonlFiles(path)
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
}))
return paths.flat()
}
describe('tmux-context through a real headless cordis.yml', () => {
it('injects one ordered tmux-location event on the first turn and suppresses the unchanged second', async () => {
let events: SessionEvent[] = []
const { stderr } = await runLoaderSmoke({
label: 'tmux-context headless smoke',
tempDirPrefix: 'tmux-context-e2e-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
inspect: async (cwd) => {
const logs = await jsonlFiles(join(cwd, '.sessions'))
expect(logs).toHaveLength(1)
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
},
})
expect(stderr).not.toContain('UNHANDLED')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
const contexts = events.filter(
(event): event is SessionEvent<'user/message'> =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tmux-context')
// Two identical-state turns: the location injects once and is suppressed after.
expect(contexts).toHaveLength(1)
const [reading] = contexts
if (reading === undefined) throw new Error('missing tmux-context reading')
const starts = events.filter(event => event.type === 'step/start')
expect(reading.seq).toBeLessThan(starts[0]!.seq)
expect(reading.surfaceOp).toBe('append')
const text = reading.data.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
expect(text).toBe(
'tmux location (turn 1):\n'
+ 'session work, window 0 "editor", pane 1 %3\n'
+ 'window active=1, pane active=1, layout a1b2,80x24,0,0,4',
)
const headers = events.filter(event => event.type === 'request/header')
expect(JSON.stringify(headers)).not.toContain('tmux location (turn')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -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> = {}): BashRunResult {
return {
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 60_000,
stdout: { text: stdout, truncated: false },
stderr: { text: '', truncated: false },
...overrides,
}
}
/** A scriptable fake `ctx.bash` recording the command it was asked to run. */
class FakeBash extends BashExecutor {
commands: string[] = []
result: BashRunResult = runResult(`${tmuxLine()}\n`)
runError?: Error
override resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? '/work',
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxPolicy: request.sandboxPolicy,
}
}
override async run(spec: BashExecSpec): Promise<BashRunResult> {
this.commands.push(spec.command)
if (this.runError) throw this.runError
return this.result
}
override start(): BashProcess {
throw new Error('tmux-context must never start a background task')
}
}
async function mount(config: Config, withBash: true): Promise<{ ctx: Context; bash: FakeBash }>
async function mount(config?: Config, withBash?: boolean): Promise<{ ctx: Context; bash: FakeBash | undefined }>
async function mount(
config: Config = {},
withBash = false,
): Promise<{ ctx: Context; bash: FakeBash | undefined }> {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
let bash: FakeBash | undefined
if (withBash) {
await ctx.plugin(FakeBash)
bash = ctx.bash as FakeBash
}
await ctx.plugin(tmuxContext, config)
return { ctx, bash }
}
function sessionAgent(session: Session, id = 'agent'): Agent {
return {
id: SessionId(id),
options: {},
session,
status: 'running',
acceptsNextStep: true,
ctx: new Context(),
followup: () => {},
steer: () => {},
inject(input) {
session.append('user/message', input, { surfaceOp: 'append' })
},
send: () => {},
cancel() {},
whenIdle: () => Promise.resolve(),
}
}
function openMessageTurn(session: Session, turn: number): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `turn ${turn}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
function contextTexts(session: Session): string[] {
const texts: string[] = []
for (const event of session.events) {
if (event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'tmux-context') {
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
}
}
return texts
}
async function fire(
ctx: Context,
agent: Agent,
turn: number,
step: number,
signal: AbortSignal = SIGNAL,
): Promise<void> {
await agentEvents(ctx, agent).serial('agent/step', turn, step, signal)
}
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('tmux-context injection', () => {
it('injects the tmux location on the first step of a turn', async () => {
const { ctx } = await mount({}, true)
const session = new Session(SessionId('first'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(contextTexts(session)).toEqual([
'tmux location (turn 1):\n'
+ 'session 0, window 1 "node", pane 2 %90\n'
+ 'window active=1, pane active=0, '
+ 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}',
])
const event = session.events.at(-1)
if (event?.type !== 'user/message') throw new Error('missing tmux context')
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' })
expect(event.surfaceOp).toBe('append')
})
it('queries the pane this process runs in and matches its controlling tty', async () => {
const { ctx, bash } = await mount({}, true)
const session = new Session(SessionId('command'))
openMessageTurn(session, 1)
await fire(ctx, sessionAgent(session), 1, 1)
expect(bash.commands).toHaveLength(1)
const command = bash.commands[0]!
expect(command).toContain('[ -n "$TMUX_PANE" ]')
expect(command).toContain('tmux display-message -t "$TMUX_PANE" -p')
// Guards against an inherited $TMUX_PANE: the pane's tty must equal this
// process's controlling tty (resolved for this exact pid).
expect(command).toContain(`ps -o tty= -p ${process.pid}`)
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/,
)
})
})

View File

@@ -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"
}
]
}