Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions src/herd.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -266,19 +266,26 @@ export function tmux(args, { runner = spawnSync, env = process.env, encoding = "
let pinTitleSupport;
/**
* Can this tmux stop an application from renaming its pane? `allow-set-title`
* arrived in 3.4. Asked once per process: the answer is a property of the
* binary, and a start plan is built for every member.
* arrived in 3.5 — not 3.4, which is what Ubuntu 24.04 (and so every
* ubuntu-latest runner) ships, and which answers `invalid option` and starts
* nothing. Asked once per process: the answer is a property of the binary,
* and a start plan is built for every member. startSession also learns the
* answer the hard way (below), so a wrong guess here costs one retry, never
* a member.
*/
export function tmuxCanPinTitle({ runner = spawnSync, force = false } = {}) {
if (pinTitleSupport !== undefined && !force) return pinTitleSupport;
let version = "";
try { version = String(runner("tmux", ["-V"], { encoding: "utf8" })?.stdout || ""); }
catch { version = ""; }
const m = /tmux\s+(?:next-)?(\d+)\.(\d+)/.exec(version);
pinTitleSupport = Boolean(m) && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 4));
pinTitleSupport = Boolean(m) && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 5));
return pinTitleSupport;
}

/** tmux's own words for "I do not have that option". */
const UNKNOWN_PIN_OPTION = /invalid option:\s*allow-set-title/i;

/**
* The shell-command tmux runs for a session.
*
Expand Down Expand Up @@ -346,9 +353,10 @@ export function tmuxStartPlan({ name, cwd, command, pinTitle = true }) {
// writes "1 awaiting input · claude agents" the moment it is up) would
// otherwise overwrite the handle through OSC 0/2, and a member whose pane
// no longer answers to its name reads as `gone` on the roster while it is
// sitting there waiting for you. tmux 3.4+; on an older tmux the option is
// unknown and the whole invocation would fail, so startSession asks
// tmuxCanPinTitle first and an old tmux keeps today's behaviour.
// sitting there waiting for you. tmux 3.5+; on an older tmux the option is
// unknown and the whole invocation fails, so startSession asks
// tmuxCanPinTitle first, retries without it if tmux still objects, and an
// old tmux keeps today's behaviour.
...(pinTitle ? [";", "set-option", "-w", "-t", name, "allow-set-title", "off"] : []),
];
}
Expand Down Expand Up @@ -668,7 +676,17 @@ export function startSession({

if (substrate === "tmux") {
const command = sessionCommand({ bin, args, stripEnv, setEnv: sessionEnv(name) });
const started = tmux(tmuxStartPlan({ name, cwd, command, pinTitle: tmuxCanPinTitle({ runner }) }), { runner, env });
let started = tmux(tmuxStartPlan({ name, cwd, command, pinTitle: tmuxCanPinTitle({ runner }) }), { runner, env });
if (!started.ok && UNKNOWN_PIN_OPTION.test(started.stderr || "")) {
// The version guess was wrong (a distro build, a version string we did
// not expect). tmux runs the plan's commands in order and stops at the
// one it rejects, so the session may already exist; the retry must not
// see it as "already running". Remember the answer for the rest of the
// process, then start it the way an older tmux can.
pinTitleSupport = false;
tmux(["kill-session", "-t", name], { runner, env });
started = tmux(tmuxStartPlan({ name, cwd, command, pinTitle: false }), { runner, env });
}
if (!started.ok) {
return { ok: false, error: new Error(started.stderr.trim() || started.error?.message || "tmux could not start the session") };
}
Expand Down
47 changes: 43 additions & 4 deletions test/herd-pinned-title.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
// its own terminal title would take it away. Seen live: Claude Code writes
// "1 awaiting input · claude agents" the moment it is up, and the member read
// as `gone` while it sat there waiting for a prompt.
//
// And the other half, seen on CI: `allow-set-title` is a tmux 3.5 option.
// Ubuntu 24.04 ships 3.4, answers "invalid option", and starts nothing — so a
// wrong guess about the version must cost a retry, never a member.
import test from "node:test";
import assert from "node:assert/strict";

import { tmuxCanPinTitle, tmuxStartPlan } from "../src/herd.mjs";
import { startSession, tmuxCanPinTitle, tmuxStartPlan } from "../src/herd.mjs";

test("the start plan pins the pane title so an engine cannot rename itself off the roster", () => {
const plan = tmuxStartPlan({ name: "api", cwd: "/x", command: "exec claude" });
Expand All @@ -24,12 +28,47 @@ test("an old tmux gets the plan without the option it does not know", () => {
assert.ok(plan.includes("-T"), "the title is still set — it is the handle for the pane on every tmux");
});

test("tmuxCanPinTitle reads the version: 3.4 or newer", () => {
test("tmuxCanPinTitle reads the version: 3.5 or newer, because 3.4 rejects the option", () => {
const at = (v) => tmuxCanPinTitle({ force: true, runner: () => ({ stdout: `tmux ${v}\n` }) });
assert.equal(at("3.6"), true);
assert.equal(at("3.4"), true);
assert.equal(at("3.5"), true);
assert.equal(at("3.5a"), true);
assert.equal(at("3.4"), false, "ubuntu 24.04's tmux — the one every ubuntu-latest runner has");
assert.equal(at("3.3a"), false);
assert.equal(at("2.9"), false);
assert.equal(at("next-3.5"), true);
assert.equal(at("next-3.6"), true);
assert.equal(tmuxCanPinTitle({ force: true, runner: () => { throw new Error("no tmux"); } }), false);
});

/**
* A tmux that claims a version the guard trusts but does not know the option:
* `-V` says 3.5, `new-session … allow-set-title` fails the way tmux 3.4 does.
*/
function tmuxThatRejectsThePin() {
const calls = [];
const runner = (bin, args) => {
calls.push(args);
if (args[0] === "-V") return { status: 0, stdout: "tmux 3.5\n", stderr: "" };
if (args.includes("new-session") && args.includes("allow-set-title")) {
return { status: 1, stdout: "", stderr: "invalid option: allow-set-title\n" };
}
// list-panes (liveNames), kill-session, the retried new-session: fine.
return { status: 0, stdout: "", stderr: "" };
};
return { calls, runner };
}

test("a tmux that rejects allow-set-title gets one retry without it, and the member starts", () => {
tmuxCanPinTitle({ force: true, runner: () => ({ stdout: "tmux 3.5\n" }) });
const { calls, runner } = tmuxThatRejectsThePin();
const result = startSession({ name: "api", engine: "claude", bin: "/bin/true", cwd: "/tmp", substrate: "tmux", runner });
assert.equal(result.ok, true, "the member must start on a tmux that does not know the option");
const starts = calls.filter((a) => a.includes("new-session"));
assert.equal(starts.length, 2, "one attempt with the pin, one without");
assert.ok(starts[0].includes("allow-set-title"));
assert.ok(!starts[1].includes("allow-set-title"));
// tmux stops at the command it rejects, after the session exists.
const killed = calls.find((a) => a[0] !== "-V" && a.includes("kill-session"));
assert.ok(killed, "the half-made session is removed before the retry");
assert.equal(tmuxCanPinTitle({ runner }), false, "the answer is remembered for the rest of the process");
});
Loading