diff --git a/src/herd.mjs b/src/herd.mjs index 66344048..040308a2 100644 --- a/src/herd.mjs +++ b/src/herd.mjs @@ -266,8 +266,12 @@ 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; @@ -275,10 +279,13 @@ export function tmuxCanPinTitle({ runner = spawnSync, force = false } = {}) { 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. * @@ -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"] : []), ]; } @@ -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") }; } diff --git a/test/herd-pinned-title.test.mjs b/test/herd-pinned-title.test.mjs index 57d7f741..8181ba8a 100644 --- a/test/herd-pinned-title.test.mjs +++ b/test/herd-pinned-title.test.mjs @@ -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" }); @@ -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"); +});