Loading files
import { execFile } from 'node:child_process'
import type { TerminalBackend } from './terminalBackend.js'
import {
TERMINAL_ACTION_SUCCEEDED,
terminalActionNotStarted,
terminalActionPossiblyExecuted,
type TerminalActionResult,
type TerminalCaptureOptions,
type TerminalCreateRequest,
type TerminalCreateResult,
type TerminalInventoryResult,
type TerminalLogicalKey,
type TerminalProcessExpectation,
type TerminalReadResult,
type TerminalRespawnRequest,
type TerminalStreamHandle,
type TerminalStreamSink,
type TerminalStreamSize,
type TmuxRuntimeRef,
type RuntimeValidation,
} from './terminalTypes.js'
import { TmuxControlStream } from './tmuxStream.js'
import {
captureTmuxPane,
ENGINE_EXIT_PANE_OPTION,
listPaneTitles,
LSTART_MARKER_RE,
lookupPaneEngineProcess,
sendKeyToTmux,
sendLiteralToTmux,
sendToTmux,
setPaneMouseOn,
setPaneWindowStyle,
} from './tmux.js'
import { DEFAULT_HOST_THEME, windowStyleOf, type HostTheme } from './hostTheme.js'
import { isNoTmuxServerError, listTmuxPanes } from './tmuxAgentDiscovery.js'
import { terminalRouteKey } from './terminalRuntime.js'
const TMUX_KEYS: Record<TerminalLogicalKey, string> = {
enter: 'Enter',
escape: 'Escape',
tab: 'Tab',
backtab: 'BTab',
up: 'Up',
down: 'Down',
left: 'Left',
right: 'Right',
home: 'Home',
end: 'End',
backspace: 'BSpace',
delete: 'DC',
pageup: 'PPage',
pagedown: 'NPage',
'ctrl-c': 'C-c',
'ctrl-d': 'C-d',
'ctrl-u': 'C-u',
'ctrl-w': 'C-w',
space: 'Space',
'0': '0',
'1': '1',
'2': '2',
'3': '3',
'4': '4',
'5': '5',
'6': '6',
'7': '7',
'8': '8',
'9': '9',
}
function legacyActionResult(ok: boolean, operation: string): TerminalActionResult {
// The legacy helper has spawned tmux before it reports false, so execution cannot safely be ruled out.
return ok ? TERMINAL_ACTION_SUCCEEDED : terminalActionPossiblyExecuted(`${operation} did not complete`)
}
/**
* The argv for removing [names] from session [sessionId]'s environment.
*
* Exported so a spec can pin the exact command without a tmux server. `-u` REMOVES the variable;
* the `-e VAR=` form that `respawn-pane` accepts would set it to an empty string instead, and an
* engine handed an empty base URL does not fall back to its own login — it dials the empty string.
*/
export function clearEnvArgs(sessionId: string, names: readonly string[]): string[] {
const args: string[] = []
for (const name of names) {
if (args.length) args.push(';')
args.push('set-environment', '-t', sessionId, '-u', name)
}
return args
}
export class TmuxBackend implements TerminalBackend<TmuxRuntimeRef> {
readonly name = 'tmux' as const
readonly instanceId = 'tmux:default'
/** What each Harness pane's `window-style` was last set to, so a scan re-applies only changes. */
private readonly styledPanes = new Map<string, string>()
private readonly desiredStyles = new Map<string, string>()
private readonly stylingPanes = new Set<string>()
/**
* [hostTheme] answers with the desktop's current pane colours (see `hostTheme.ts`); read at every
* use rather than captured, so a theme the app sends later reaches sessions created after it.
*/
constructor(private readonly hostTheme: () => HostTheme = () => DEFAULT_HOST_THEME) {}
async create(request: TerminalCreateRequest): Promise<TerminalCreateResult<TmuxRuntimeRef>> {
const args = ['new-session', '-d', '-P', '-F', '#{pane_id}']
if (request.cwd) args.push('-c', request.cwd)
if (request.label) args.push('-s', request.label)
// `-e` (tmux 3.2+) puts these in the SESSION environment rather than the launch argv, so a grid
// relay key never lands in `ps` output for the life of the agent. Callers gate on
// `tmuxSupportsSessionEnv()`; an older tmux answers the flag with its usage text.
for (const [key, value] of Object.entries(request.env ?? {})) args.push('-e', `${key}=${value}`)
// Trailing args after this point become the session's shell-command. tmux execs them directly
// (no shell interposed) when given as separate argv elements, so no quoting/escaping is needed.
if (request.command?.length) args.push(...request.command)
// Keep the pane when its process dies, so an engine that exits immediately (not logged in, bad
// config) still has its error text readable afterwards instead of taking the whole session down
// with it. Chained into THIS tmux invocation on purpose: an engine can exit in under a
// millisecond, and a second `set-option` call loses that race — measured, the session was
// already gone before the follow-up command could reach the server.
// Whoever created the pane owns turning this back off; see `clearPaneRemainOnExit`.
args.push(';', 'set-option', '-w', 'remain-on-exit', 'on')
// Same invocation, same reason: an engine asks its terminal for its colours (OSC 10/11) in its
// first milliseconds and never again, so the style has to be there before the engine is.
const style = windowStyleOf(this.hostTheme())
args.push(';', 'set-option', '-w', 'window-style', style)
// `killed`/`signal` come from execFile's own error shape, which ErrnoException alone does not declare.
type ExecError = NodeJS.ErrnoException & { killed?: boolean; signal?: NodeJS.Signals | null }
const result = await new Promise<{ error: ExecError | null; stdout: string; stderr: string }>((resolve) => {
execFile('tmux', args, { timeout: 5_000 }, (error, stdout, stderr) => resolve({
error: error as ExecError | null,
stdout,
stderr,
}))
})
if (result.error) {
if (result.error.code === 'ENOENT') return terminalActionNotStarted('tmux is unavailable')
// tmux says exactly why it refused — "duplicate session", "protocol version mismatch",
// a .tmux.conf error, a directory it cannot enter. Dropping stderr here turned every one of
// those into the same unactionable SPAWN_FAILED, diagnosable only by reading the daemon log
// on the machine that failed, which does not have the reason either.
const detail = result.stderr.trim().split('\n')[0]?.slice(0, 200)
// execFile reports a timeout kill as SIGTERM with no stderr — the one failure whose cause
// is not in tmux's own output.
|| (result.error.killed ? 'tmux did not answer within 5s' : result.error.message.slice(0, 200))
return terminalActionPossiblyExecuted(`tmux session creation did not complete: ${detail}`)
}
const paneId = result.stdout.trim()
if (!/^%\d+$/.test(paneId)) {
return terminalActionPossiblyExecuted('tmux created a session without returning its root pane')
}
await setPaneMouseOn(paneId)
this.styledPanes.set(paneId, style)
return { state: 'succeeded', dispatch: 'executed', runtime: { backend: 'tmux', paneId } }
}
/**
* Replace the process running in an existing pane, with a different environment.
*
* `-k` kills what is there first; without it tmux refuses a live pane. `remain-on-exit` is turned on
* in the SAME invocation and for the same reason as in `create`: a respawned engine that dies
* immediately (a rejected key, a model the grid does not serve) must leave its error on screen
* instead of taking the pane down with it. Whoever calls this owns turning it back off.
*/
async respawn(runtime: TmuxRuntimeRef, request: TerminalRespawnRequest): Promise<TerminalActionResult> {
// The engine-exit marker is the pane's, and a respawned pane is a new launch: cleared here, in
// the same invocation, or the new engine would read as exited the moment it started.
const args = [
'set-option', '-w', '-t', runtime.paneId, 'remain-on-exit', 'on', ';',
'set-option', '-p', '-t', runtime.paneId, ENGINE_EXIT_PANE_OPTION, '', ';',
'respawn-pane', '-k',
]
if (request.cwd) args.push('-c', request.cwd)
for (const [key, value] of Object.entries(request.env ?? {})) args.push('-e', `${key}=${value}`)
args.push('-t', runtime.paneId, ...request.command)
const result = await new Promise<{ error: NodeJS.ErrnoException | null; stderr: string }>((resolve) => {
execFile('tmux', args, { timeout: 5_000 }, (error, _stdout, stderr) => resolve({
error: error as NodeJS.ErrnoException | null,
stderr,
}))
})
if (!result.error) return TERMINAL_ACTION_SUCCEEDED
if (result.error.code === 'ENOENT') return terminalActionNotStarted('tmux is unavailable')
// `-k` means the old process may already be gone even though the new one never started, so this
// cannot be reported as "nothing happened".
const detail = result.stderr.trim().split('\n')[0]?.slice(0, 200) || result.error.message.slice(0, 200)
return terminalActionPossiblyExecuted(`tmux could not respawn the pane: ${detail}`)
}
async kill(runtime: TmuxRuntimeRef): Promise<TerminalActionResult> {
if (!/^%\d+$/.test(runtime.paneId)) return terminalActionNotStarted('invalid tmux pane identity')
// Discovered harnesses can share a tmux session with unrelated work. The
// canonical pane id is the entire target; never widen this to kill-session.
const ok = await new Promise<boolean>((resolve) => {
execFile('tmux', ['kill-pane', '-t', runtime.paneId], { timeout: 5_000 }, (error) => resolve(!error))
})
if (ok) return TERMINAL_ACTION_SUCCEEDED
// The engine may have exited and removed its pane before the parallel PID
// check completed. Only authoritative inventory makes that an idempotent success.
// Use all panes here: discovery deliberately hides sessions that were renamed
// or created elsewhere, and their absence from discovery is not proof of exit.
const absent = await new Promise<boolean>(resolve => {
execFile('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], { timeout: 2_000 }, (error, stdout) => {
if (error) { resolve(isNoTmuxServerError(error.message)); return }
const ids = stdout.trim().split('\n').filter(Boolean)
resolve(ids.every(id => /^%\d+$/.test(id)) && !ids.includes(runtime.paneId))
})
})
if (absent) return TERMINAL_ACTION_SUCCEEDED
return legacyActionResult(false, 'tmux pane close')
}
/**
* Remove [names] from the environment of the session that owns [runtime].
*
* A pane created by `new-session -e` put those variables in the SESSION environment, and
* `respawn-pane` inherits it — so respawning with no `-e` flags would leave the old grid in place
* while reporting a clean swap. This is what makes "back to your own login" actually true.
*/
async clearEnv(runtime: TmuxRuntimeRef, names: readonly string[]): Promise<TerminalActionResult> {
if (!names.length) return legacyActionResult(true, 'tmux clear environment')
const sessionId = await this.resolveSessionId(runtime.paneId)
if (!sessionId) return terminalActionNotStarted('tmux session could not be resolved from pane')
const ok = await new Promise<boolean>((resolve) => {
execFile('tmux', clearEnvArgs(sessionId, names), { timeout: 5_000 }, (error) => resolve(!error))
})
return legacyActionResult(ok, 'tmux clear environment')
}
/** The id of the session that owns [paneId], or null when tmux will not say. */
private resolveSessionId(paneId: string): Promise<string | null> {
return new Promise((resolve) => {
execFile('tmux', ['display-message', '-p', '-t', paneId, '#{session_id}'], { timeout: 2_000 }, (error, stdout) => {
const value = stdout.trim()
resolve(!error && /^\$\d+$/.test(value) ? value : null)
})
})
}
/**
* Re-arm `remain-on-exit` on an already-live pane, mirroring what `create()` does at spawn time.
*
* Restart must call this BEFORE killing the pane's engine process. `clearPaneRemainOnExit` turns
* this off the moment an agent is first confirmed (see `cli.ts`'s `onCreateAgent`), so without
* re-arming it here tmux destroys the pane — and, being its only pane, the whole session — the
* instant the old process exits.
*/
async holdOpen(runtime: TmuxRuntimeRef): Promise<TerminalActionResult> {
const ok = await new Promise<boolean>((resolve) => {
execFile('tmux', ['set-option', '-w', '-t', runtime.paneId, 'remain-on-exit', 'on'], { timeout: 2_000 }, (error) => {
resolve(!error)
})
})
return legacyActionResult(ok, 'tmux remain-on-exit re-arm')
}
async titles(): Promise<TerminalReadResult<Map<string, string>>> {
const titles = await listPaneTitles()
return {
state: 'succeeded',
value: new Map([...titles].map(([paneId, title]) => [terminalRouteKey({ backend: 'tmux', paneId }), title])),
}
}
async inventory(): Promise<TerminalInventoryResult> {
const result = await listTmuxPanes()
if (!result.ok) return { state: 'unavailable', reason: result.error }
this.restyle(result.panes.map((pane) => pane.tmuxPane))
return {
state: 'available',
roots: result.panes.map((pane) => ({
runtime: { backend: 'tmux' as const, paneId: pane.tmuxPane },
rootPid: pane.rootPid,
cwd: pane.cwd,
})),
}
}
/**
* Retroactive, on every scan: a pane from before this build, one that outlived a daemon restart,
* or every pane after the app changed its palette. Fire-and-forget — a scan that misses one
* because tmux was briefly slow catches it on the next pass. Panes that are gone are forgotten
* so a reused pane id is styled afresh.
*/
private restyle(panes: readonly string[]): void {
const style = windowStyleOf(this.hostTheme())
const live = new Set(panes)
for (const pane of this.desiredStyles.keys()) {
if (!live.has(pane)) this.desiredStyles.delete(pane)
}
for (const pane of this.styledPanes.keys()) {
if (!live.has(pane)) this.styledPanes.delete(pane)
}
for (const pane of panes) {
this.desiredStyles.set(pane, style)
this.applyStyle(pane)
}
}
private applyStyle(pane: string): void {
const style = this.desiredStyles.get(pane)
if (style === undefined || this.stylingPanes.has(pane) || this.styledPanes.get(pane) === style) return
// Serialize writes per pane: scans can overlap a slow tmux command or a theme change.
this.stylingPanes.add(pane)
void setPaneWindowStyle(pane, style).then((applied) => {
if (applied && this.desiredStyles.has(pane)) this.styledPanes.set(pane, style)
}).finally(() => {
this.stylingPanes.delete(pane)
// Apply a newer theme immediately, but retry a failed unchanged write on the next scan.
if (this.desiredStyles.get(pane) !== style) this.applyStyle(pane)
})
}
async validate(runtime: TmuxRuntimeRef, expected: TerminalProcessExpectation): Promise<RuntimeValidation> {
try {
const found = await lookupPaneEngineProcess(runtime.paneId, expected.engine)
if (!found.ok) return { state: found.unknown ? 'unknown' : 'gone', reason: found.reason }
const live = found.identity
// A saved marker that is not a C-locale `lstart` stamp was written either by the pre-fix parser,
// with its fields shifted, or by a `ps` that still inherited the user's LC_TIME (see psEnv in
// lib/childLocale.ts). Either way it can never equal the corrected stamp for the same live
// process, so comparing it would report a running engine as gone — once, on the upgrade that
// fixed the reading. The pane still has a matching engine process; take it.
//
// Resume's checkSessionRuntime makes the same allowance. The coordinator passes persisted
// identities directly here when validating or acquiring a terminal lease.
if (expected.processIdentity && !LSTART_MARKER_RE.test(expected.processIdentity.startMarker)) {
return { state: 'alive' }
}
if (expected.processIdentity
&& (expected.processIdentity.pid !== live.pid || expected.processIdentity.startMarker !== live.startMarker)) {
return { state: 'gone', reason: 'process changed under tmux pane' }
}
return { state: 'alive' }
} catch {
return { state: 'unknown', reason: 'tmux runtime probe failed' }
}
}
async capture(runtime: TmuxRuntimeRef, options: TerminalCaptureOptions = {}): Promise<TerminalReadResult<string>> {
const captured = await captureTmuxPane(runtime.paneId, options.historyLines, {
visible: options.mode === 'visible',
ansi: options.ansi,
})
return captured === null
? { state: 'failed', reason: 'tmux capture failed' }
: { state: 'succeeded', value: captured }
}
async typeLiteral(runtime: TmuxRuntimeRef, text: string): Promise<TerminalActionResult> {
return legacyActionResult(await sendLiteralToTmux(runtime.paneId, text), 'tmux literal input')
}
async submitText(runtime: TmuxRuntimeRef, text: string): Promise<TerminalActionResult> {
return legacyActionResult(await sendToTmux(runtime.paneId, text), 'tmux submission')
}
async sendKey(runtime: TmuxRuntimeRef, key: TerminalLogicalKey): Promise<TerminalActionResult> {
return legacyActionResult(await sendKeyToTmux(runtime.paneId, TMUX_KEYS[key]), 'tmux key input')
}
async setTitle(runtime: TmuxRuntimeRef, title: string): Promise<TerminalActionResult> {
const ok = await new Promise<boolean>((resolve) => {
execFile('tmux', ['select-pane', '-t', runtime.paneId, '-T', title.slice(0, 200)], { timeout: 2_000 }, (error) => {
resolve(!error)
})
})
return legacyActionResult(ok, 'tmux title update')
}
async notify(runtime: TmuxRuntimeRef, title: string, body: string): Promise<TerminalActionResult> {
const message = `${title.slice(0, 200)}: ${body.slice(0, 1_000)}`
const ok = await new Promise<boolean>((resolve) => {
execFile('tmux', ['display-message', '-t', runtime.paneId, '--', message], { timeout: 2_000 }, (error) => {
resolve(!error)
})
})
return legacyActionResult(ok, 'tmux notification')
}
/**
* Stream a pane's bytes. Deliberately NOT gated on identifying the engine process in it.
*
* Streaming and injection need different thresholds, and they used to share one. This path
* addresses the PANE — `capture-pane -t %N` out, `send-keys -t %N` in — and a tmux pane id is
* monotonic and never reused within a server, so the id alone is a safe address. Injection is the
* one that types into whatever process owns the pane, and it keeps validating: see
* `TerminalBackendCoordinator.validateLease` and the lease dispatch fallbacks. The reaper
* (`coordinator.validate`) keeps validating too.
*
* Requiring a match here made the pane unviewable in exactly the situations where seeing it is the
* whole point: an engine that crashed, one stopped at a first-run prompt under a process the
* matcher does not cover, or a pane that has fallen back to a bare shell. The agent was listed, and
* clicking it produced "TERMINAL FROZEN · no <engine> process under tmux pane" instead of the
* screen that would have explained why. A pane that is genuinely gone still fails, one line later:
* `TmuxControlStream.open` reads `paneMeta` first and refuses a missing pane and a multi-pane
* window. Dropping the check also takes a whole-process-table `ps` scan off every terminal open.
*
* `expected` stays in the signature because `TerminalBackend` defines it; it is intentionally
* unused here.
*/
async openStream(
runtime: TmuxRuntimeRef,
expected: TerminalProcessExpectation,
size: TerminalStreamSize,
sink: TerminalStreamSink,
readOnly?: boolean,
): Promise<TerminalReadResult<TerminalStreamHandle<TmuxRuntimeRef>>> {
void expected
return TmuxControlStream.open(runtime.paneId, size, sink, readOnly)
}
}
56ae2e47fThis file is pinned to the selected release. Download the original to open it in your design tool.