fix(mux): treat the launch directory as no answer for a discovered pane

Every pane starts in TMUX_LAUNCH_CWD (/tmp) and reaches its workspace with a
cd inside the pane command, but a remote or docker pane never does that cd,
so tmux reports /tmp for it. A rediscovered record carries no remote/docker
metadata and looks local, so adopting that directory would root the session
at /tmp: hooks written into /tmp/.claude, the Files panel at /tmp, and a
respawn running claude in a directory any local account can write to.

reconcileSessions() now skips a pane whose directory is the launch directory,
in both spellings a pane can report it: the literal one and its realpath
(/private/tmp on macOS, where a process cwd comes back resolved). The set is
computed once at module load. Such a session falls back to process.cwd(),
as before this series.

Also stop trimming the list-panes output: the directory is the last field,
so a trim cut a trailing space off the last row's path; parsePaneRows()
already skips empty lines. And the comments that list what a discovered
record knows for certain now include the directory, when tmux reported a
usable one.

Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
committed 2026-10-21 08:02:05 +02:00
1 parent 3bd7e81233
commit ced88b3310
3 files changed
+82 -6

No files matched your search

+49 -1
View File
@@ -52,11 +52,24 @@ vi.mock('node:child_process', async () => {
};
});
// Paths realpathSync should resolve differently than this host does, e.g. a
// macOS `/tmp` -> `/private/tmp`. Hoisted so it survives vi.resetModules().
const realpathOverrides = vi.hoisted(() => new Map<string, string>());
// Mock fs to avoid file I/O
vi.mock('node:fs', async () => {
const actual = await vi.importActual('node:fs');
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
const realpathSync = Object.assign(
vi.fn((path: string, ...rest: unknown[]) =>
realpathOverrides.has(String(path))
? realpathOverrides.get(String(path))
: (actual.realpathSync as (...args: unknown[]) => string)(path, ...rest)
),
{ native: actual.realpathSync.native }
);
return {
...actual,
realpathSync,
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn(),
mkdirSync: vi.fn(),
@@ -916,6 +929,10 @@ describe('TmuxManager (unit)', () => {
'codeman-cccc3333|104|0|||/home/user/$(touch pwned)',
// A tmux that reported no path.
'codeman-dddd4444|105|0|||',
// The launch directory every pane starts in; a remote or docker pane never leaves it.
'codeman-eeee5555|106|0|||/tmp',
// Last row, path ending in a space: the output must not be trimmed.
'codeman-ffff6666|107|0|||/home/user/trailing ',
].join('\n');
}
return '';
@@ -930,6 +947,8 @@ describe('TmuxManager (unit)', () => {
'restored-bbbb2222',
'restored-cccc3333',
'restored-dddd4444',
'restored-eeee5555',
'restored-ffff6666',
]);
const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir]));
expect(dirs).toEqual({
@@ -937,6 +956,8 @@ describe('TmuxManager (unit)', () => {
'codeman-bbbb2222': '/home/user/project-b',
'codeman-cccc3333': process.cwd(),
'codeman-dddd4444': process.cwd(),
'codeman-eeee5555': process.cwd(),
'codeman-ffff6666': '/home/user/trailing ',
});
// The directory comes from the same list-panes call, as its last field.
@@ -949,6 +970,33 @@ describe('TmuxManager (unit)', () => {
nonTestManager.destroy();
}
});
it('treats the resolved spelling of the launch directory as no answer too (macOS /private/tmp)', async () => {
// On macOS /tmp is a symlink and a pane reports its process cwd resolved.
realpathOverrides.set('/tmp', '/private/tmp');
mockedExecSync.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes(' list-panes ')) {
return ['codeman-aaaa1111|101|0|||/private/tmp', 'codeman-bbbb2222|102|0|||/tmp'].join('\n');
}
return '';
});
try {
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
const nonTestManager = new NonTestTmuxManager();
try {
await nonTestManager.reconcileSessions();
const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir]));
expect(dirs).toEqual({
'codeman-aaaa1111': process.cwd(),
'codeman-bbbb2222': process.cwd(),
});
} finally {
nonTestManager.destroy();
}
} finally {
realpathOverrides.clear();
}
});
});
});