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'))