Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,35 @@ Launching a new remote session (#222) and injection over the messaging socket
at is the failure this refuses; an unparseable count is treated the same
as "someone's there" rather than guessed.

- **Solo attach parity, issue #253.** A solo attach now makes the remote
tmux session look and behave like a local terminal instead of a plain
multiplexer view: `buildAttachCommand(socket, target, { solo, pre })`
prefixes the attach with three session-scoped (never `-g`, never `-w`)
`tmux ... \; ...` sets — `status off`, `mouse on`, `window-size latest` —
when `solo` is true, and emits the unchanged pre-#253 command when it
isn't (shared attach never touches another client's view). The probe
(`buildProbeCommand`) now also reads `mouse` and `window-size` alongside
`status`, and `parseProbeOutput` returns their raw pre-attach values as
`pre: { status, mouse, windowSize }` (`null` when an option is absent
from the probe output) in addition to the existing `cols`/`rows`.
`tmux show-options -A` marks an option inherited from a higher scope
with a trailing `*` on the option name (e.g. `status* on`, measured on
tmux 3.6) — `pre.<opt>` is `null` for both "absent" and "inherited
(starred)", since both mean no session override exists and restore
must `set -u`; it is non-null only for an actual session-scoped
override (unstarred), restored via `set -t`. The star never affects
the sizing rule — a starred `status* off`/`on`/`<n>` sizes
`statusLines` exactly like its unstarred form. On
detach, when the attach was solo, the adapter fires a best-effort,
fire-and-forget `buildRestoreCommand(socket, target, pre)` ssh call that
sets each option back to its probed value (`set -t <target> <name>
<value>`) or, when the probed value was `null`, unsets the session
override (`set -u -t <target> <name>`) so the host's own global option
applies again. The restore call's failure is only logged — it never
throws out of `detach()` and never blocks the local ssh client from being
killed. No shared-attach restore is ever sent, because a shared attach
never applied the options in the first place.

- **This is the first thing to populate the session-handle seam from issue
#220** (see `.ai/contexts/trigger-watcher.md`, "Session handle"): a
remote-attach entry sets `host: alias`, `kind: 'remote-attach'`, and
Expand Down
102 changes: 85 additions & 17 deletions remote-attach.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,30 +27,62 @@ function isSafeSocketPath(value) {
return typeof value === 'string' && value.length > 0 && value.length <= 4096 && !/['"\\\s]/.test(value);
}

function escapeRegExpLiteral(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", sizing rule)
// "status* on" = inherited, "status on" = session override — see .ai/contexts/session-cache.md ("solo attach parity, issue #253")
function parseOptionToken(part, name) {
const re = new RegExp(`${escapeRegExpLiteral(name)}(\\*?)\\s+(\\S+)`);
const m = re.exec(part || '');
if (!m) return { value: null, inherited: false };
return { value: m[2], inherited: m[1] === '*' };
}

function parseProbeOutput(stdout) {
const text = typeof stdout === 'string' ? stdout : '';
const idx = text.indexOf(PROBE_SEP);
const sizePart = idx === -1 ? text : text.slice(0, idx);
const statusPart = idx === -1 ? '' : text.slice(idx + PROBE_SEP.length);
const parts = text.split(PROBE_SEP);
const sizePart = parts[0] || '';
const statusPart = parts[1] || '';
const mousePart = parts[2] || '';
const windowSizePart = parts[3] || '';

const sizeMatch = /(\d+)x(\d+)/.exec(sizePart);
if (!sizeMatch) return null;
const width = Number.parseInt(sizeMatch[1], 10);
const height = Number.parseInt(sizeMatch[2], 10);

let statusLines = DEFAULT_STATUS_LINES;
const statusMatch = /status\s+(\S+)/.exec(statusPart);
if (statusMatch) {
if (statusMatch[1] === 'off') statusLines = 0;
else if (statusMatch[1] === 'on') statusLines = 1;
let status = null;
const statusParsed = parseOptionToken(statusPart, 'status');
if (statusParsed.value != null) {
if (statusParsed.value === 'off') { statusLines = 0; status = 'off'; }
else if (statusParsed.value === 'on') { statusLines = 1; status = 'on'; }
else {
const n = Number.parseInt(statusMatch[1], 10);
if (Number.isFinite(n) && n >= 0) statusLines = n;
const n = Number.parseInt(statusParsed.value, 10);
if (Number.isFinite(n) && n >= 0) { statusLines = n; status = n; }
}
}

return { cols: width, rows: height + statusLines };
let mouse = null;
const mouseParsed = parseOptionToken(mousePart, 'mouse');
if (mouseParsed.value === 'on' || mouseParsed.value === 'off') mouse = mouseParsed.value;

let windowSize = null;
const windowSizeParsed = parseOptionToken(windowSizePart, 'window-size');
if (['latest', 'largest', 'smallest', 'manual'].includes(windowSizeParsed.value)) windowSize = windowSizeParsed.value;

// pre.<opt> non-null only for a session-scoped override; null means restore by `set -u`
return {
cols: width,
rows: height + statusLines,
pre: {
status: statusParsed.inherited ? null : status,
mouse: mouseParsed.inherited ? null : mouse,
windowSize: windowSizeParsed.inherited ? null : windowSize,
},
};
}

// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", socket discovery)
Expand All @@ -60,11 +92,35 @@ function buildProbeCommand(pid, target) {
`printf '%s${PROBE_SEP}' "$sock"; ` +
`tmux -S "$sock" display-message -p -t ${target} '#{window_width}x#{window_height}'` +
`; printf '${PROBE_SEP}'; tmux -S "$sock" show-options -A -t ${target} status 2>/dev/null` +
`; printf '${PROBE_SEP}'; tmux -S "$sock" show-options -A -t ${target} mouse 2>/dev/null` +
`; printf '${PROBE_SEP}'; tmux -S "$sock" show-options -A -t ${target} window-size 2>/dev/null` +
`; printf '${PROBE_SEP}'; tmux -S "$sock" list-clients -t ${target} 2>/dev/null | wc -l`;
}

function buildAttachCommand(socket, target) {
return `tmux -S '${socket}' attach -t ${target}`;
// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", solo attach parity, issue #253)
function buildAttachCommand(socket, target, opts = {}) {
if (!opts.solo) {
return `tmux -S '${socket}' attach -t ${target}`;
}
return `tmux -S '${socket}' set -t ${target} status off \\; ` +
`set -t ${target} mouse on \\; ` +
`set -t ${target} window-size latest \\; ` +
`attach -t ${target}`;
}

// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", solo attach parity, issue #253)
function buildRestoreOptionSegment(target, name, value) {
return value == null ? `set -u -t ${target} ${name}` : `set -t ${target} ${name} ${value}`;
}

function buildRestoreCommand(socket, target, pre) {
const p = pre || {};
const segments = [
buildRestoreOptionSegment(target, 'status', p.status),
buildRestoreOptionSegment(target, 'mouse', p.mouse),
buildRestoreOptionSegment(target, 'window-size', p.windowSize),
];
return `tmux -S '${socket}' ${segments.join(' \\; ')}`;
}

// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", solo vs shared)
Expand All @@ -76,15 +132,16 @@ function parseClientCount(text) {
}

// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", socket discovery)
// parts: [socket, size, status, mouse, window-size, clientCount]; trailing ones optional
function parseDiscoveryProbeOutput(stdout) {
const text = typeof stdout === 'string' ? stdout : '';
const parts = text.split(PROBE_SEP);
const socket = parts[0] || '';
if (parts.length < 3 || !isSafeSocketPath(socket)) return null;
const size = parseProbeOutput(parts.slice(1, 3).join(PROBE_SEP));
if (!size) return null;
const clientCount = parseClientCount(parts[3]);
return { socket, cols: size.cols, rows: size.rows, clientCount };
const probed = parseProbeOutput(parts.slice(1, 5).join(PROBE_SEP));
if (!probed) return null;
const clientCount = parseClientCount(parts[5]);
return { socket, cols: probed.cols, rows: probed.rows, pre: probed.pre, clientCount };
}

// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach")
Expand Down Expand Up @@ -205,7 +262,7 @@ function createTmuxAttachAdapter(opts = {}) {
const openRows = solo ? localSize.rows : discovery.rows;

const sshPath = resolveSshPath();
const argv = ['-tt', '-o', 'BatchMode=yes', alias, buildAttachCommand(discovery.socket, parsed.target)];
const argv = ['-tt', '-o', 'BatchMode=yes', alias, buildAttachCommand(discovery.socket, parsed.target, { solo, pre: discovery.pre })];

let raw;
try {
Expand All @@ -227,6 +284,16 @@ function createTmuxAttachAdapter(opts = {}) {
if (detaching || !alive) return;
detaching = true;
try { raw.kill(); } catch {}
if (solo) {
// best-effort restore — see .ai/contexts/session-cache.md ("solo attach parity, issue #253")
try {
const restoreCmd = buildRestoreCommand(discovery.socket, parsed.target, discovery.pre);
Promise.resolve(runRemoteCommand(alias, restoreCmd, { timeoutMs: DEFAULT_PROBE_TIMEOUT_MS }))
.catch((err) => log.warn(`[remote-attach:${alias}] restore-on-detach failed: ${err && err.message}`));
} catch (err) {
log.warn(`[remote-attach:${alias}] restore-on-detach failed: ${err && err.message}`);
}
}
}

const ptyProcess = {
Expand Down Expand Up @@ -265,4 +332,5 @@ module.exports = {
parseDiscoveryProbeOutput,
buildProbeCommand,
buildAttachCommand,
buildRestoreCommand,
};
Loading
Loading