fix: adopted tmux targets, hung servers and the attach chain (release review)

Names tmux reads as target syntax (`@1`, `%1`, `=eq`, a bare `~`) passed the
has-session guard by name and then joined something else in new-session -t:
another session, or a fresh shell on the foreign server. They are refused at
discovery now, and take the existing skipped note.

One stopped tmux server emptied the whole local list and left a stuck client
per scan. Each server now gets a bounded ping with stdout at /dev/null first,
because a tmux client hands dups of its stdout to the server: a stopped server
holds our pipe open after the client is dead, so a watchdog around list-panes
cannot work. The probe runs in its own process group, all three transports
keep the rows read before a failure, and a missed ping becomes a note.

The attach and the probe pass -u, so a POSIX-locale container no longer garbles
the tab or lists a non-ASCII name as underscores. A docker attach gets the same
TERM and COLORTERM an owned container's exec gets.

A missing tmux no longer reads as "session is gone", and the launch line's `$`
now reaches bash unexpanded, so the ssh attach's PATH line is expanded at the
far end, as the probe's already was.

fix-plan/write, fix-plan/read and ralph-prompt/write refuse an adopted session:
its workingDir is the neutral /tmp, so they read and wrote a predictable name
in a folder anyone can write to.

FOREIGN_MAX_SOCKETS is gone: it capped nothing, and a real cap in glob order
would never reach `default` on a box with many `codeman-*` sockets (this one
has 212). Closing an adopted tab re-reads the outside-sessions list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 04:33:42 +01:00
1 parent 36d8942744
commit aa40cca343
9 files changed
+620 -81

No files matched your search

+10 -13
View File
@@ -29,6 +29,7 @@ import {
buildForeignAttachCommand,
buildForeignDockerAttachCommand,
buildForeignRemoteAttachCommand,
quoteAdoptLaunch,
} from './foreign-tmux.js';
import { execSync, exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
@@ -2730,13 +2731,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
// is the neutral wrapper folder, not where the foreign pane is.
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
execSync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
{
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
}
);
const launchArg = adopt ? quoteAdoptLaunch(launchCmd) : JSON.stringify(launchCmd);
execSync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${launchArg}`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
// Wait for tmux session to be queryable
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
@@ -2994,12 +2993,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
// is the neutral wrapper folder, not where the foreign pane is.
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
await execAsync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
{
timeout: EXEC_TIMEOUT_MS,
}
);
const launchArg = adopt ? quoteAdoptLaunch(launchCmd) : JSON.stringify(launchCmd);
await execAsync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${launchArg}`, {
timeout: EXEC_TIMEOUT_MS,
});
// Wait for the respawned process to start
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
// The pane now runs a fresh command, so whatever the last read observed of