/** * 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, for the first request (`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, PreStepDecision } 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 pre-step processing. */ export const inject = ['agents'] /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ export interface Config { /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ refreshIntervalMs?: number } /** Schemastery validation for {@link Config}. */ export const Config: z = z.object({ refreshIntervalMs: z.number(), }) /** * Tab-separated tmux format fields, in query order. Layout (`window_layout`) * is the pane-tree description; pane/window pixel sizes are intentionally * excluded (own location and layout only, per the package scope). */ const TMUX_FIELDS = [ '#{session_name}', '#{window_index}', '#{window_name}', '#{pane_index}', '#{pane_id}', '#{window_active}', '#{pane_active}', '#{window_layout}', ] as const /** Structured tmux location parsed from one `display-message` reading. */ interface TmuxLocation { sessionName: string windowIndex: string windowName: string paneIndex: string paneId: string windowActive: string paneActive: string windowLayout: string } /** Prefix marking the volatile turn/step preamble line of a rendered reading. */ const READING_PREFIX = 'tmux location (turn ' /** * Field separator between tmux format fields. tmux does not interpret C escapes * in a format, so the literal two-character sequence `\t` is emitted verbatim * and split back out here; this avoids embedding raw whitespace in the command. */ const FIELD_SEP = '\\t' /** * Read this process's tmux location through the bash seam, or `undefined` when * this process is not genuinely running inside a tmux pane or the query fails. * * `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell * (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and * `$TMUX_PANE` from that ancestor, so the variables are present even though this * process does not live in that pane. The command therefore also compares the * pane's `#{pane_tty}` against this process's own controlling terminal * (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty, * an inherited environment names some other pane's tty. Fields are emitted only * on a match, so an inherited environment reads as "not in tmux" and injects * nothing. * * 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 { 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 pre-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/pre-step', async ( { agent, turn, step, signal }, next, ): Promise => { const decision = await next() if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision const bash = ctx.get('bash') if (bash === undefined) return decision const previous = latestInjectedState(agent) if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) { const now = Date.now() if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision } const location = await queryTmuxLocation(bash, ctx.logger, process.pid, signal) if (location === undefined) return decision const state = renderState(location) if (previous !== undefined && previous.state === state) return decision const text = renderReading(location, turn) return { kind: 'enter', messages: [ createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] }, }), ...decision.messages, ], } }, { prepend: true }) }