COD-106 shared/collaborative remote tmux sessions (window-size latest + shared badge)

Two Codeman clients attaching the same durable remote tmux session at different
viewports would fight: tmux sizes a window to the SMALLEST attached client by
default. Push `window-size latest` to the remote session config so the window
tracks the most-recently-active client instead, letting concurrent clients
coexist; surface the client count for a "shared · N" badge.

Reconciled onto upstream PR #145: #145 moved the durable remote session onto the
dedicated `-L codeman-remote` socket under a `codeman-ssh-` name and scoped every
tmux set-option PER-SESSION (`set -t <name>`, never `-g`) so a shared remote tmux
server's OTHER sessions keep their own prefix/mouse/sizing. The original COD-106
commit added `set -g window-size latest` (GLOBAL) on the old `-L codeman` socket —
a regression against #145's hardening. This commit layers the window-size feature
onto #145's structure as `set -t <name> window-size latest` (per-session, on the
codeman-remote socket). Test assertions updated to the per-session form
(remote-shared-sessions.test.ts) and the byte-identical launch-command test
(remote-ssh-options.test.ts) extended with the window-size line — which supersedes
the separate f09323c9 assertion fix (dropped: it targeted the global form and also
carried unrelated CLAUDE.md doc changes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aamer Akhter
2026-07-17 16:03:03 -04:00
co-authored by Claude Opus 4.8
parent 6dba8b5227
commit b2ebdcbf47
7 changed files with 72 additions and 10 deletions
+5 -1
View File
@@ -270,9 +270,13 @@ export function parseRemoteSessionList(stdout: string): RemoteSessionInfo[] {
const created = Number(createdStr); const created = Number(createdStr);
const windows = Number(windowsStr); const windows = Number(windowsStr);
if (!Number.isFinite(created) || !Number.isFinite(windows)) continue; if (!Number.isFinite(created) || !Number.isFinite(windows)) continue;
// COD-106 — `session_attached` is the CLIENT COUNT (not a 0/1 flag); >1 = shared.
const attachedNum = Number(attachedStr.trim());
const attachedClients = Number.isFinite(attachedNum) ? Math.max(0, Math.trunc(attachedNum)) : 0;
out.push({ out.push({
name, name,
attached: attachedStr.trim() === '1', attached: attachedClients > 0,
attachedClients,
created: Math.trunc(created), created: Math.trunc(created),
windows: Math.trunc(windows), windows: Math.trunc(windows),
}); });
+7
View File
@@ -805,6 +805,13 @@ export function buildRemoteLaunchCommand(options: {
`set -t ${remoteName} mouse off`, `set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix C-q`, `set -t ${remoteName} prefix C-q`,
'set -s escape-time 0', 'set -s escape-time 0',
// COD-106 — shared/collaborative sessions: tmux defaults to sizing a window
// to the SMALLEST attached client, so two Codemans at different viewports
// would fight (clamp to the smaller). `window-size latest` sizes to the
// most-recently-active client instead, so concurrent clients coexist.
// Per-session scoped (`set -t <name>`, matching #145's hardening) so a shared
// remote tmux server's other sessions keep their own sizing behavior.
`set -t ${remoteName} window-size latest`,
].join(' \\; '); ].join(' \\; ');
// ssh runs its trailing args through the remote login shell, so the entire // ssh runs its trailing args through the remote login shell, so the entire
+3 -1
View File
@@ -127,8 +127,10 @@ export interface SessionRemote extends RemoteSshOptions {
export interface RemoteSessionInfo { export interface RemoteSessionInfo {
/** tmux session name (always starts `codeman-`). */ /** tmux session name (always starts `codeman-`). */
name: string; name: string;
/** Whether a client is currently attached to the remote session. */ /** Whether at least one client is currently attached to the remote session. */
attached: boolean; attached: boolean;
/** COD-106 — number of clients attached (tmux `session_attached`); >1 = shared. */
attachedClients: number;
/** tmux `session_created` epoch seconds. */ /** tmux `session_created` epoch seconds. */
created: number; created: number;
/** Number of windows in the remote session. */ /** Number of windows in the remote session. */
+9 -3
View File
@@ -1858,9 +1858,15 @@ Object.assign(CodemanApp.prototype, {
const rows = sessions.map(s => { const rows = sessions.map(s => {
const ageSecs = Math.max(0, now - (s.created || 0)); const ageSecs = Math.max(0, now - (s.created || 0));
const age = ageSecs < 3600 ? `${Math.floor(ageSecs / 60)}m` : ageSecs < 86400 ? `${Math.floor(ageSecs / 3600)}h` : `${Math.floor(ageSecs / 86400)}d`; const age = ageSecs < 3600 ? `${Math.floor(ageSecs / 60)}m` : ageSecs < 86400 ? `${Math.floor(ageSecs / 3600)}h` : `${Math.floor(ageSecs / 86400)}d`;
const attachedBadge = s.attached // COD-106 — show "shared · N clients" when more than one client is attached
? '<span class="case-location-badge" style="background: var(--accent, #61afef);">attached</span>' // (genuinely collaborative), else a plain "attached" badge for a single client.
: ''; const clients = s.attachedClients != null ? s.attachedClients : s.attached ? 1 : 0;
const attachedBadge =
clients > 1
? `<span class="case-location-badge" style="background: var(--warning, #e5c07b); color: #000;">shared · ${clients} clients</span>`
: clients === 1
? '<span class="case-location-badge" style="background: var(--accent, #61afef);">attached</span>'
: '';
return ` return `
<div class="remote-discover-item"> <div class="remote-discover-item">
<div class="remote-discover-info"> <div class="remote-discover-info">
+7 -5
View File
@@ -29,8 +29,8 @@ describe('COD-105 parseRemoteSessionList', () => {
const stdout = 'codeman-disco1\t0\t1700000000\t1\n' + 'codeman-abcd1234\t1\t1700000123\t3\n'; const stdout = 'codeman-disco1\t0\t1700000000\t1\n' + 'codeman-abcd1234\t1\t1700000123\t3\n';
const list = parseRemoteSessionList(stdout); const list = parseRemoteSessionList(stdout);
expect(list).toEqual([ expect(list).toEqual([
{ name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }, { name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1700000000, windows: 1 },
{ name: 'codeman-abcd1234', attached: true, created: 1700000123, windows: 3 }, { name: 'codeman-abcd1234', attached: true, attachedClients: 1, created: 1700000123, windows: 3 },
]); ]);
}); });
@@ -40,8 +40,8 @@ describe('COD-105 parseRemoteSessionList', () => {
const stdout = 'codeman-disco1\\t0\\t1781362858\\t1\n' + 'codeman-real\\t1\\t1781329905\\t2\n'; const stdout = 'codeman-disco1\\t0\\t1781362858\\t1\n' + 'codeman-real\\t1\\t1781329905\\t2\n';
const list = parseRemoteSessionList(stdout); const list = parseRemoteSessionList(stdout);
expect(list).toEqual([ expect(list).toEqual([
{ name: 'codeman-disco1', attached: false, created: 1781362858, windows: 1 }, { name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1781362858, windows: 1 },
{ name: 'codeman-real', attached: true, created: 1781329905, windows: 2 }, { name: 'codeman-real', attached: true, attachedClients: 1, created: 1781329905, windows: 2 },
]); ]);
}); });
@@ -59,7 +59,9 @@ describe('COD-105 parseRemoteSessionList', () => {
it('tolerates malformed lines (missing columns) by skipping them', () => { it('tolerates malformed lines (missing columns) by skipping them', () => {
const stdout = 'codeman-ok\t0\t1700000000\t1\n' + 'codeman-bad\tnotanumber\n'; const stdout = 'codeman-ok\t0\t1700000000\t1\n' + 'codeman-bad\tnotanumber\n';
const list = parseRemoteSessionList(stdout); const list = parseRemoteSessionList(stdout);
expect(list).toEqual([{ name: 'codeman-ok', attached: false, created: 1700000000, windows: 1 }]); expect(list).toEqual([
{ name: 'codeman-ok', attached: false, attachedClients: 0, created: 1700000000, windows: 1 },
]);
}); });
}); });
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
import { parseRemoteSessionList } from '../src/remote-hosts.js';
import type { SessionRemote } from '../src/types.js';
// COD-106 — shared/collaborative remote sessions: window-size policy so concurrent
// clients don't fight, and a client-count surfaced for the "shared · N" badge.
describe('COD-106 shared remote sessions', () => {
const remote: SessionRemote = {
hostId: 'h',
label: 'aa',
host: '192.168.55.170',
username: 'aakht',
remotePath: '/tmp',
commands: { shell: 'exec bash -l' },
};
it('launch command sets window-size latest (so multi-client attach does not clamp to smallest)', () => {
const cmd = buildRemoteLaunchCommand({ mode: 'shell', remote, sessionId: 'cod106aaa' });
// Per-session scoped on the dedicated `codeman-remote` socket (PR #145 hardening).
expect(cmd).toContain('set -t codeman-ssh-cod106aa window-size latest');
// still has the COD-104 config (no regression)
expect(cmd).toContain('set -t codeman-ssh-cod106aa status off');
expect(cmd).toContain('new-session -A -s codeman-ssh-cod106aa');
});
it('parses session_attached as a CLIENT COUNT (>1 = shared)', () => {
const rows = parseRemoteSessionList(
['codeman-solo\\t1\\t100\\t1', 'codeman-shared\\t2\\t200\\t3', 'codeman-idle\\t0\\t300\\t1'].join('\n')
);
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['codeman-solo'].attachedClients).toBe(1);
expect(byName['codeman-solo'].attached).toBe(true);
expect(byName['codeman-shared'].attachedClients).toBe(2); // shared
expect(byName['codeman-shared'].attached).toBe(true);
expect(byName['codeman-idle'].attachedClients).toBe(0);
expect(byName['codeman-idle'].attached).toBe(false);
});
});
+2
View File
@@ -157,6 +157,8 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
`set -t ${remoteName} mouse off`, `set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix C-q`, `set -t ${remoteName} prefix C-q`,
'set -s escape-time 0', 'set -s escape-time 0',
// COD-106 — shared/collaborative sizing, per-session scoped (never -g).
`set -t ${remoteName} window-size latest`,
].join(' \\; '); ].join(' \\; ');
// Connection args (with the default -o ConnectTimeout=10) sit after -t. // Connection args (with the default -o ConnectTimeout=10) sit after -t.
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`; const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;