Merge commit '90bc53cc64dc684dc3d741f45ae6a8d548c188f9' into worktree/retarget-pr831-20260729

This commit is contained in:
Tianyi Cui
2026-07-29 21:40:28 +08:00
133 changed files with 11971 additions and 945 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/README.md
README.md: a5244dfe99a714605744b57d33f97359d4d6fa4e
README.zh.md: 6036d58c6937d025adc36d9b2d12f51e396ee3d0
README.md: fce6e21816d261171aaeaa217171580adb7c43f9
README.zh.md: b8a4d68ca6892b51ed52479a7296513f5edcc292

View File

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

View File

@@ -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智能体和会话各自隔离的方式以及相应的生命周期拆分。

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: a166a46d20f472cb5d8f045e2456ce3e6de7a2f2
README.zh.md: 0575d549e352239e7d954870eaf40beea1169cc6

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. 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.
## 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 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。由于位置信息是可选的,执行器的拒绝——`resolve()` 的策略拒绝或 `run()` 的基础设施故障——会被兜住并记录为警告,而不会使该轮失败。
状态在每个符合条件的轮次拉取——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,48 @@
{
"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-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,241 @@
/**
* 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: an executor
* rejection is contained and logged as a warning so the turn continues.
*
* @module @deepseek-ai/dsh-tmux-context
*/
import type { Context, LoggerService } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { BashExecutor, BashRunResult } 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.
*
* 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<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')
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)
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, ctx.logger, 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,407 @@
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
resolveError?: Error
override resolve(request: BashExecRequest): BashExecSpec {
if (this.resolveError) throw this.resolveError
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('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'))
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,37 @@
{
"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/invariants"
},
{
"path": "../../core/session"
}
]
}