From b2ebdcbf477239a93916c2b2db56dea5fee53bb6 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 13 Jun 2026 15:17:21 -0400 Subject: [PATCH] COD-106 shared/collaborative remote tmux sessions (window-size latest + shared badge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `, 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 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) --- src/remote-hosts.ts | 6 ++++- src/tmux-manager.ts | 7 ++++++ src/types/session.ts | 4 ++- src/web/public/session-ui.js | 12 ++++++--- test/remote-discover-attach.test.ts | 12 +++++---- test/remote-shared-sessions.test.ts | 39 +++++++++++++++++++++++++++++ test/remote-ssh-options.test.ts | 2 ++ 7 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 test/remote-shared-sessions.test.ts diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 92620454..571924a3 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -270,9 +270,13 @@ export function parseRemoteSessionList(stdout: string): RemoteSessionInfo[] { const created = Number(createdStr); const windows = Number(windowsStr); 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({ name, - attached: attachedStr.trim() === '1', + attached: attachedClients > 0, + attachedClients, created: Math.trunc(created), windows: Math.trunc(windows), }); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d4951474..24b5d539 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -805,6 +805,13 @@ export function buildRemoteLaunchCommand(options: { `set -t ${remoteName} mouse off`, `set -t ${remoteName} prefix C-q`, '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 `, 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(' \\; '); // ssh runs its trailing args through the remote login shell, so the entire diff --git a/src/types/session.ts b/src/types/session.ts index 9cac4a71..3cf4d2fd 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -127,8 +127,10 @@ export interface SessionRemote extends RemoteSshOptions { export interface RemoteSessionInfo { /** tmux session name (always starts `codeman-`). */ 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; + /** COD-106 — number of clients attached (tmux `session_attached`); >1 = shared. */ + attachedClients: number; /** tmux `session_created` epoch seconds. */ created: number; /** Number of windows in the remote session. */ diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 2ac91eab..4896b9fd 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -1858,9 +1858,15 @@ Object.assign(CodemanApp.prototype, { const rows = sessions.map(s => { 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 attachedBadge = s.attached - ? 'attached' - : ''; + // COD-106 — show "shared · N clients" when more than one client is attached + // (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 + ? `shared · ${clients} clients` + : clients === 1 + ? 'attached' + : ''; return `
diff --git a/test/remote-discover-attach.test.ts b/test/remote-discover-attach.test.ts index dac9e63b..193bdf1b 100644 --- a/test/remote-discover-attach.test.ts +++ b/test/remote-discover-attach.test.ts @@ -29,8 +29,8 @@ describe('COD-105 parseRemoteSessionList', () => { const stdout = 'codeman-disco1\t0\t1700000000\t1\n' + 'codeman-abcd1234\t1\t1700000123\t3\n'; const list = parseRemoteSessionList(stdout); expect(list).toEqual([ - { name: 'codeman-disco1', attached: false, created: 1700000000, windows: 1 }, - { name: 'codeman-abcd1234', attached: true, created: 1700000123, windows: 3 }, + { name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1700000000, windows: 1 }, + { 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 list = parseRemoteSessionList(stdout); expect(list).toEqual([ - { name: 'codeman-disco1', attached: false, created: 1781362858, windows: 1 }, - { name: 'codeman-real', attached: true, created: 1781329905, windows: 2 }, + { name: 'codeman-disco1', attached: false, attachedClients: 0, created: 1781362858, windows: 1 }, + { 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', () => { const stdout = 'codeman-ok\t0\t1700000000\t1\n' + 'codeman-bad\tnotanumber\n'; 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 }, + ]); }); }); diff --git a/test/remote-shared-sessions.test.ts b/test/remote-shared-sessions.test.ts new file mode 100644 index 00000000..786c7b4f --- /dev/null +++ b/test/remote-shared-sessions.test.ts @@ -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); + }); +}); diff --git a/test/remote-ssh-options.test.ts b/test/remote-ssh-options.test.ts index f5d3b910..81dbeb44 100644 --- a/test/remote-ssh-options.test.ts +++ b/test/remote-ssh-options.test.ts @@ -157,6 +157,8 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => { `set -t ${remoteName} mouse off`, `set -t ${remoteName} prefix C-q`, 'set -s escape-time 0', + // COD-106 — shared/collaborative sizing, per-session scoped (never -g). + `set -t ${remoteName} window-size latest`, ].join(' \\; '); // 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)}`;