/** * THE TMUX TRANSPORT — every `tmux` shell-out in this package, in one file. * * Before this, `spawnSync("tmux", …)` appeared at 7 sites in `tools/transport.ts` * and the `has-session` caveat below was written out TWICE, verbatim, at two of * them. A rule duplicated in two comments is a rule that will be re-derived * wrongly at the third site somebody adds. * * WHAT THIS FILE DOES NOT DO, on purpose: spawn or reap the pusher process, * read receipts, or schedule reminders. Delivery is a second process for THIS * transport and will not be for a socket one, so that machinery stays with the * tools that own it and reaches this file through `TmuxHost` below. An interface * that baked in a pusher could not host an implementation that has none. */ import { spawnSync } from "node:child_process"; import type { ControlCommand, Liveness, Transport, TransportMarker, TransportKind } from "./types.js"; import { TMUX_PUSH_REMOTE, isLocallyProbeable, isTmuxKind, targetOf } from "./types.js"; /** * The parts of delivery that belong to the PROCESS layer, not to tmux. * * Injected rather than imported to keep the dependency pointing one way: * `tools/transport.ts` owns pusher spawn, receipts and marker files, and hands * them here. Importing them back would make this module and that one mutually * dependent, and a cycle is how "one place for the literal" quietly becomes two. */ export type TmuxHost = { attach(args: { agentId: string; target?: string; includeRoom?: boolean; allowlist?: string[]; debounceMs?: number; }): Promise; detach(agentId: string): Promise; push(marker: TransportMarker, text: string): Promise<{ delivered: boolean; error?: string }>; sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string }>; /** Is the pusher process behind this marker still running on this host? */ pusherAlive(marker: TransportMarker): boolean; /** Stop a wedged pusher. Returns false if it could not be signalled. */ killPusher(marker: TransportMarker): boolean; }; /** `tmux -V` — is tmux on this host at all? */ export function tmuxAvailable(): boolean { return spawnSync("tmux", ["-V"]).status === 0; } /** * DOES THIS TARGET EXIST? The only tmux probe with discriminating power. * * `has-session` VALIDATES THE TARGET; `display-message -p -t "ok"` * DOES NOT — tmux exits 0 for any target, including a pane killed a moment ago, * so that probe had ZERO discriminating power and reported every dead pane * alive. Pinned to the BEHAVIOUR, not a version: measured identical on tmux * 3.6b, 3.7b. A version-pinned claim rots on the next upgrade. * * Positive control, both directions, re-run on tmux 3.7b while extracting this: * bogus target `%99999` -> has-session exit 1, display-message exit 0; live pane * `%8` -> both exit 0. The wrong probe is wrong in only one direction, which is * why it survived: it never reports a live pane dead. */ export function paneExists(target: string): boolean { return spawnSync("tmux", ["has-session", "-t", target]).status === 0; } /** * `has-session` with the failure text, for the one caller that reports it. * * `paneExists` is the boolean most sites want; attach quotes tmux's own stderr * back to the user, and losing that text would make a refusal less useful while * still typechecking — the exact class of silent regression this task guards. */ export function probePane(target: string): { exists: boolean; stderr: string } { const probe = spawnSync("tmux", ["has-session", "-t", target]); return { exists: probe.status === 0, stderr: (probe.stderr ?? "").toString().trim() }; } /** `tmux -V` output, or undefined when tmux is absent. */ export function tmuxVersion(): string | undefined { const probe = spawnSync("tmux", ["-V"]); if (probe.status !== 0) return undefined; return (probe.stdout ?? "").toString().trim() || undefined; } export class TmuxTransport implements Transport { readonly kind: TransportKind; #host: TmuxHost; // ⟨q-ec020f6a⟩ local tmux-push deleted: this class now only ever backs the remote kind, // so the default (used only by tests that omit `kind`) follows that — everything else // in this file is unchanged, per the slice's "keep TmuxTransport fully intact" scope. constructor(host: TmuxHost, kind: TransportKind = TMUX_PUSH_REMOTE) { this.#host = host; this.kind = kind; } available(): boolean { return tmuxAvailable(); } attach(args: { agentId: string; target?: string; includeRoom?: boolean; allowlist?: string[]; debounceMs?: number; }): Promise { return this.#host.attach(args); } detach(agentId: string): Promise { return this.#host.detach(agentId); } push(marker: TransportMarker, text: string): Promise<{ delivered: boolean; error?: string }> { return this.#host.push(marker, text); } sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string }> { return this.#host.sendControl(marker, cmd); } /** * THREE ANSWERS, AND THE THIRD IS LOAD-BEARING. * * Each `unknown` below was previously a `continue` with a comment. The states * are preserved exactly, because "we could not look" is not evidence of death * and a reaper that cannot tell them apart kills live sessions: * * · not a tmux marker -> unknown (this transport cannot speak for it) * · remote (foreign host) -> unknown (no local pane; heartbeat decides) * · no target recorded -> unknown (nothing to probe) * · tmux missing on host -> unknown (the instrument is absent, not the pane) * · target absent -> dead * · pusher gone, pane alive -> dead, and it says which half failed */ async probe(marker: TransportMarker): Promise { if (!isTmuxKind(marker.transport)) { return { state: "unknown", reason: `transport "${marker.transport}" is not tmux` }; } if (!isLocallyProbeable(marker.transport)) { return { state: "unknown", reason: `${marker.transport} runs on another host${marker.host ? ` (${marker.host})` : ""} — no local pane to probe; liveness is heartbeat-based`, }; } const target = targetOf(marker); if (!target) return { state: "unknown", reason: "no target recorded on the marker" }; if (!tmuxAvailable()) return { state: "unknown", reason: "tmux is not available on this host" }; if (!paneExists(target)) return { state: "dead", reason: `pane ${target} does not exist` }; if (!this.#host.pusherAlive(marker)) { return { state: "dead", reason: `pane ${target} is alive but its pusher (pid ${marker.pid}) is gone` }; } return { state: "live" }; } /** * Reap pushers whose pane has gone, and say which ones could not be judged. * * `unprobeable` is NOT a residual list nobody reads — it is the distinction * PRODUCTION_ROADMAP Phase 5.3 requires between "dead" and "cannot probe". * An agent whose liveness is unknown is left alone and reported, never reaped. */ async reapWedged(markers: TransportMarker[]): Promise<{ reaped: string[]; unprobeable: string[] }> { const reaped: string[] = []; const unprobeable: string[] = []; for (const marker of markers) { const live = await this.probe(marker); if (live.state === "unknown") { unprobeable.push(marker.agentId); continue; } if (live.state === "live") continue; if (this.#host.killPusher(marker)) reaped.push(marker.agentId); else unprobeable.push(marker.agentId); } return { reaped, unprobeable }; } }