diff --git a/src/session.ts b/src/session.ts
index daeb1667..839053b1 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -49,6 +49,7 @@ import {
type CodexConfig,
type EffortLevel,
type GeminiConfig,
+ type SessionRemote,
} from './types.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
@@ -209,6 +210,10 @@ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: nu
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
}
+export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote): string {
+ return remote ? '/tmp' : workingDir;
+}
+
/**
* Represents a JSON message from Claude CLI's stream-json output format.
* Messages are newline-delimited JSON objects parsed from PTY output.
@@ -385,6 +390,9 @@ export class Session extends EventEmitter {
// tmux history-limit (scrollback lines) applied to this session's pane.
private readonly _tmuxHistoryLimit: number;
+ // Remote execution metadata, present when this session runs over SSH through local tmux.
+ private readonly _remote?: SessionRemote;
+
// Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default';
@@ -456,6 +464,8 @@ export class Session extends EventEmitter {
tmuxHistoryLimit?: number;
/** Restored per-session attachment history. May include server-private external paths. */
attachmentHistory?: SessionAttachmentHistoryItem[];
+ /** Remote execution metadata for sessions launched through SSH inside local tmux. */
+ remote?: SessionRemote;
}
) {
super();
@@ -528,6 +538,7 @@ export class Session extends EventEmitter {
this._effort = config.effort;
}
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
+ this._remote = config.remote;
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
this.restoreAttachmentHistory(config.attachmentHistory);
}
@@ -987,6 +998,7 @@ export class Session extends EventEmitter {
pid: this.pid,
status: this._status,
workingDir: this.workingDir,
+ remote: this._remote,
currentTaskId: this._currentTaskId,
createdAt: this.createdAt,
lastActivityAt: this._lastActivityAt,
@@ -1171,7 +1183,7 @@ export class Session extends EventEmitter {
name: 'xterm-256color',
cols: ptyCols,
rows: ptyRows,
- cwd: this.workingDir,
+ cwd: resolveMuxAttachCwd(this.workingDir, this._remote),
env: buildMuxAttachEnv(),
});
} catch (spawnErr) {
@@ -1282,6 +1294,7 @@ export class Session extends EventEmitter {
envOverrides: this._envOverrides,
effort: this._effort,
historyLimit: this._tmuxHistoryLimit,
+ remote: this._remote,
},
createSessionOptions: {
sessionId: this.id,
@@ -1299,6 +1312,7 @@ export class Session extends EventEmitter {
envOverrides: this._envOverrides,
effort: this._effort,
historyLimit: this._tmuxHistoryLimit,
+ remote: this._remote,
},
spawnErrLabel: 'mux attachment',
});
@@ -1637,6 +1651,7 @@ export class Session extends EventEmitter {
niceConfig: this._niceConfig,
envOverrides: this._envOverrides,
historyLimit: this._tmuxHistoryLimit,
+ remote: this._remote,
},
createSessionOptions: {
sessionId: this.id,
@@ -1646,6 +1661,7 @@ export class Session extends EventEmitter {
niceConfig: this._niceConfig,
envOverrides: this._envOverrides,
historyLimit: this._tmuxHistoryLimit,
+ remote: this._remote,
},
spawnErrLabel: 'shell mux attachment',
});
diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts
index 0af5caa8..8f5c52ed 100644
--- a/src/tmux-manager.ts
+++ b/src/tmux-manager.ts
@@ -42,8 +42,10 @@ import {
type CodexConfig,
type EffortLevel,
type GeminiConfig,
+ type SessionRemote,
} from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
+import { defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
import {
wrapWithNice,
SAFE_PATH_PATTERN,
@@ -669,6 +671,16 @@ function buildSpawnCommand(options: {
return '$SHELL';
}
+export function buildRemoteLaunchCommand(options: { mode: SessionMode; remote: SessionRemote }): string {
+ const { mode, remote } = options;
+ const modeCommand = remote.commands?.[mode] || defaultRemoteCommandForMode(mode);
+ const args = ['ssh', '-o', 'BatchMode=yes', '-t'];
+ if (remote.port) args.push('-p', String(remote.port));
+ const remoteCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
+ args.push(remoteSshTarget(remote), `bash -lc ${shellescape(remoteCommand)}`);
+ return args.map((arg) => shellescape(arg)).join(' ');
+}
+
/**
* Set sensitive environment variables on a tmux session via setenv.
* These are inherited by panes but not visible in ps output or tmux history.
diff --git a/src/types/api.ts b/src/types/api.ts
index 1e03a3d6..b7201022 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -130,7 +130,6 @@ export interface CaseInfo {
/** Remote case metadata for display and session creation */
remote?: {
hostId: string;
- hostLabel: string;
host: string;
username: string;
path: string;
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 3f6edd2d..bf699c72 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -1649,6 +1649,36 @@
Absolute path to an existing project folder, e.g. /home/you/my-project
+
+
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index 733276b3..6dae2ebc 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -71,9 +71,12 @@ Object.assign(CodemanApp.prototype, {
const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile
cases.forEach(c => {
- const displayName = c.name.length > maxNameLength
- ? c.name.substring(0, maxNameLength) + '…'
+ const baseLabel = c.location === 'remote' && c.remote
+ ? `${c.name} @ ${c.remote.hostId}`
: c.name;
+ const displayName = baseLabel.length > maxNameLength
+ ? baseLabel.substring(0, maxNameLength) + '…'
+ : baseLabel;
options += ``;
});
@@ -1254,6 +1257,18 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('newCaseDescription').value = '';
document.getElementById('linkCaseName').value = '';
document.getElementById('linkCasePath').value = '';
+ const remoteFields = [
+ 'remoteCaseName',
+ 'remoteCasePath',
+ 'remoteHostId',
+ 'remoteHostAddress',
+ 'remoteHostUsername',
+ 'remoteHostCodexCommand',
+ ];
+ remoteFields.forEach(id => {
+ const el = document.getElementById(id);
+ if (el) el.value = '';
+ });
// Reset to first tab
this.caseModalTab = 'case-create';
this.switchCaseModalTab('case-create');
@@ -1407,6 +1422,64 @@ Object.assign(CodemanApp.prototype, {
}
},
+ async linkRemoteCase() {
+ const name = document.getElementById('remoteCaseName').value.trim();
+ const remotePath = document.getElementById('remoteCasePath').value.trim();
+ const hostId = document.getElementById('remoteHostId').value.trim();
+ const host = document.getElementById('remoteHostAddress').value.trim();
+ const username = document.getElementById('remoteHostUsername').value.trim();
+ const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
+
+ if (!name || !remotePath || !hostId || !host || !username) {
+ this.showToast('Please complete all required remote fields', 'error');
+ return;
+ }
+ if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
+ this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
+ return;
+ }
+ if (!remotePath.startsWith('/')) {
+ this.showToast('Remote path must be absolute', 'error');
+ return;
+ }
+
+ try {
+ const hostPayload = {
+ id: hostId,
+ label: hostId,
+ host,
+ username,
+ ...(codexCommand ? { commands: { codex: codexCommand } } : {}),
+ };
+ const hostRes = await fetch('/api/remote-hosts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(hostPayload)
+ });
+ const hostData = await hostRes.json();
+ if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
+ throw new Error(hostData.error || 'Failed to save remote host');
+ }
+
+ const caseRes = await fetch('/api/cases/remote-link', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, hostId, remotePath })
+ });
+ const caseData = await caseRes.json();
+ if (caseData.success) {
+ this.closeCreateCaseModal();
+ this.showToast(`Remote case "${name}" linked`, 'success');
+ await this.loadQuickStartCases(name);
+ await this.saveLastUsedCase(name);
+ } else {
+ this.showToast(caseData.error || 'Failed to link remote case', 'error');
+ }
+ } catch (err) {
+ console.error('Failed to link remote case:', err);
+ this.showToast('Failed to link remote case: ' + err.message, 'error');
+ }
+ },
// ═══════════════════════════════════════════════════════════════
// Case Management (reorder + delete)
diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts
index 35b9fa82..9eb1395f 100644
--- a/src/web/routes/case-routes.ts
+++ b/src/web/routes/case-routes.ts
@@ -95,21 +95,26 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
const host = remoteHostMap.get(remoteCase.hostId);
- if (!host || existingNames.has(remoteCase.name) || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
+ if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
existingNames.add(remoteCase.name);
- cases.push({
+ const remoteCaseInfo: CaseInfo = {
name: remoteCase.name,
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
hasClaudeMd: false,
location: 'remote',
remote: {
hostId: host.id,
- hostLabel: host.label,
host: host.host,
username: host.username,
path: remoteCase.remotePath,
},
- });
+ };
+ const existingIndex = cases.findIndex((item) => item.name === remoteCase.name);
+ if (existingIndex === -1) {
+ cases.push(remoteCaseInfo);
+ } else {
+ cases[existingIndex] = remoteCaseInfo;
+ }
}
// Sort by persisted caseOrder from settings.json
@@ -341,6 +346,25 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
+ const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
+ const remoteCase = remoteCases.find((item) => item.name === name);
+ if (remoteCase) {
+ const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
+ if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
+ return {
+ name,
+ path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
+ hasClaudeMd: false,
+ location: 'remote',
+ remote: {
+ hostId: host.id,
+ host: host.host,
+ username: host.username,
+ path: remoteCase.remotePath,
+ },
+ };
+ }
+
const casePath = await resolveCasePath(name);
if (!existsSync(casePath)) {
diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts
index 659731ad..64411d2d 100644
--- a/src/web/routes/session-routes.ts
+++ b/src/web/routes/session-routes.ts
@@ -1293,24 +1293,36 @@ export function registerSessionRoutes(
}
}
- // Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
- // This mirrors the behaviour of resolveCasePath() in case-routes so that linked
- // external project directories are honoured by quick-start just like regular case routes.
- let linkedCases: Record = {};
- try {
- const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
- linkedCases = JSON.parse(raw);
- } catch {
- // File missing or unparseable — treat as empty registry
- }
- const linkedCasePath = linkedCases[caseName];
- const casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
- if (!casePath) {
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
+ let remote = undefined;
+ let linkedCasePath: string | undefined;
+ let casePath: string | null = null;
+ const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
+ const remoteCase = remoteCases.find((item) => item.name === caseName);
+ if (remoteCase) {
+ const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
+ if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
+ casePath = remoteCase.remotePath;
+ remote = toSessionRemote(host, remoteCase);
+ } else {
+ // Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
+ // This mirrors the behaviour of resolveCasePath() in case-routes so that linked
+ // external project directories are honoured by quick-start just like regular case routes.
+ let linkedCases: Record = {};
+ try {
+ const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
+ linkedCases = JSON.parse(raw);
+ } catch {
+ // File missing or unparseable — treat as empty registry
+ }
+ linkedCasePath = linkedCases[caseName];
+ casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
+ if (!casePath) {
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
+ }
}
- // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked cases)
- if (!existsSync(casePath)) {
+ // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases)
+ if (!remote && !existsSync(casePath)) {
try {
mkdirSync(casePath, { recursive: true });
mkdirSync(join(casePath, 'src'), { recursive: true });
@@ -1343,6 +1355,7 @@ export function registerSessionRoutes(
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
+ !remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
) {
@@ -1379,12 +1392,13 @@ export function registerSessionRoutes(
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
envOverrides,
effort,
+ remote,
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
});
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
// so the initial state already has the phrase configured (only if globally enabled)
- if (mode === 'claude' && ctx.store.getConfig().ralphEnabled) {
+ if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) {
autoConfigureRalph(session, casePath, ctx);
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
diff --git a/test/routes/case-routes.test.ts b/test/routes/case-routes.test.ts
index 2cc6fc21..21e6fd11 100644
--- a/test/routes/case-routes.test.ts
+++ b/test/routes/case-routes.test.ts
@@ -232,7 +232,7 @@ describe('case-routes', () => {
const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' });
expect(list.statusCode).toBe(200);
- expect(JSON.parse(list.body)).toEqual([
+ expect(JSON.parse(list.body).data).toEqual([
expect.objectContaining({ id: 'gpu-box', label: 'GPU Box', commands: { codex: 'exec codx personal' } }),
]);
});
@@ -255,12 +255,38 @@ describe('case-routes', () => {
expect(link.statusCode).toBe(200);
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
- expect(JSON.parse(cases.body)).toContainEqual(
+ expect(JSON.parse(cases.body).data).toContainEqual(
+ expect.objectContaining({
+ name: 'gpu-work',
+ location: 'remote',
+ path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
+ remote: expect.objectContaining({ hostId: 'gpu-box', path: '/home/ubuntu/work' }),
+ })
+ );
+ });
+
+ it('prefers remote case metadata over a same-name local managed case', async () => {
+ setupRemoteConfigStore();
+ mockedReaddir.mockResolvedValue([{ name: 'gpu-work', isDirectory: () => true }] as never);
+
+ await harness.app.inject({
+ method: 'POST',
+ url: '/api/remote-hosts',
+ payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
+ });
+ await harness.app.inject({
+ method: 'POST',
+ url: '/api/cases/remote-link',
+ payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
+ });
+ mockedExistsSync.mockReturnValue(true);
+
+ const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
+ expect(JSON.parse(cases.body).data).toContainEqual(
expect.objectContaining({
name: 'gpu-work',
location: 'remote',
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
- remote: expect.objectContaining({ hostId: 'gpu-box', hostLabel: 'GPU Box', path: '/home/ubuntu/work' }),
})
);
});
diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts
index 7f0d8a39..5bfa2ad6 100644
--- a/test/routes/session-routes.test.ts
+++ b/test/routes/session-routes.test.ts
@@ -19,6 +19,7 @@ import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
+import { Session } from '../../src/session.js';
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
@@ -481,6 +482,166 @@ describe('session-routes', () => {
expect(body.data.terminalBuffer).toBeDefined();
});
+ it('does not strip VPA-like shell scrollback as Ink redraw bloat', async () => {
+ const shellHistory = Array.from(
+ { length: 3000 },
+ (_, index) => `SHELL_SCROLLBACK_${String(index + 1).padStart(6, '0')} payload payload payload \x1b[1d`
+ ).join('\n');
+ harness.ctx._session.terminalBuffer = shellHistory;
+ harness.ctx._session.mode = 'shell';
+ (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(() => null);
+
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_000001');
+ expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_003000');
+ });
+
+ it('preserves accumulated history before the live mux pane snapshot for Codex TUI replay', async () => {
+ harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
+ harness.ctx._session.mode = 'codex';
+ (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
+ () => 'visible tmux pane only\n› current prompt'
+ );
+
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.data.terminalBuffer).toContain('hello world');
+ expect(body.data.terminalBuffer).toContain('later accumulated history');
+ expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
+ expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
+ body.data.terminalBuffer.indexOf('visible tmux pane only')
+ );
+ expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
+ });
+
+ it('treats stale Codex scrollback config as TUI replay', async () => {
+ harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
+ harness.ctx._session.mode = 'codex';
+ harness.ctx._session.codexConfig = { renderMode: 'scrollback' } as any;
+ (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
+ () => 'visible tmux pane only\n› current prompt'
+ );
+
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.data.terminalBuffer).toContain('hello world');
+ expect(body.data.terminalBuffer).toContain('later accumulated history');
+ expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
+ expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
+ body.data.terminalBuffer.indexOf('visible tmux pane only')
+ );
+ expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
+ });
+
+ it('preserves one-time OAuth authorization URLs in Codex TUI replay history', async () => {
+ const authUrl =
+ 'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
+ harness.ctx._session.terminalBuffer =
+ 'Authorize `atlassian` by opening this URL in your browser:\n' +
+ authUrl +
+ '\n(Browser launch failed; please copy the URL above manually.)\n';
+ harness.ctx._session.mode = 'codex';
+ (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
+ () => 'visible tmux pane only\n› current prompt'
+ );
+
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.data.terminalBuffer).toContain(authUrl);
+ expect(body.data.terminalBuffer).toContain('visible tmux pane only');
+ expect(body.data.terminalBuffer.indexOf(authUrl)).toBeLessThan(
+ body.data.terminalBuffer.indexOf('visible tmux pane only')
+ );
+ });
+
+ it('preserves incidental OAuth URL mentions as ordinary Codex TUI history', async () => {
+ const authUrl =
+ 'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
+ harness.ctx._session.terminalBuffer =
+ 'Root cause: URLs like ' +
+ authUrl +
+ ' could be present in history but missing from browser-rendered terminal replay.\n' +
+ "+ 'Authorize `atlassian` by opening this URL in your browser:\\n' +\n";
+ harness.ctx._session.mode = 'codex';
+ (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
+ () => 'visible tmux pane only\n› current prompt'
+ );
+
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.data.terminalBuffer).toContain(authUrl);
+ expect(body.data.terminalBuffer).toContain('visible tmux pane only');
+ });
+
+ it('preserves accumulated history before a live mux pane snapshot for non-Codex sessions', async () => {
+ harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
+ harness.ctx._session.mode = 'claude';
+ (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
+ () => 'visible tmux pane only\n› current prompt'
+ );
+
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.data.terminalBuffer).toContain('hello world');
+ expect(body.data.terminalBuffer).toContain('later accumulated history');
+ expect(body.data.terminalBuffer).toContain('visible tmux pane only');
+ expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
+ expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
+ body.data.terminalBuffer.indexOf('visible tmux pane only')
+ );
+ expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
+ });
+
+ it('uses live mux pane capture only when the accumulated buffer is empty', async () => {
+ harness.ctx._session.terminalBuffer = '';
+ harness.ctx._session.mode = 'codex';
+ (harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
+ () => 'visible restored tmux pane\n› current prompt'
+ );
+
+ const res = await harness.app.inject({
+ method: 'GET',
+ url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.data.terminalBuffer).toContain('visible restored tmux pane');
+ expect(body.data.terminalBuffer).toContain('› current prompt');
+ expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
+ });
+
it('returns error for unknown session', async () => {
const res = await harness.app.inject({
method: 'GET',
@@ -765,6 +926,76 @@ describe('session-routes', () => {
// ========== POST /api/sessions (with resumeSessionId) ==========
describe('POST /api/sessions with resumeSessionId', () => {
+ it('creates session from a remote case without local stat validation', async () => {
+ remoteStore.hosts = [
+ {
+ id: 'gpu-box',
+ label: 'GPU Box',
+ host: '10.0.0.42',
+ username: 'ubuntu',
+ commands: { codex: 'exec codx personal' },
+ },
+ ];
+ remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
+
+ const res = await harness.app.inject({
+ method: 'POST',
+ url: '/api/sessions',
+ payload: { caseName: 'gpu-work', mode: 'shell', name: 'Remote Shell' },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.success).toBe(true);
+ expect(body.data.session.workingDir).toBe('/home/ubuntu/work');
+ expect(body.data.session.remote).toMatchObject({
+ hostId: 'gpu-box',
+ host: '10.0.0.42',
+ username: 'ubuntu',
+ remotePath: '/home/ubuntu/work',
+ commands: { codex: 'exec codx personal' },
+ });
+ });
+
+ it('quick-start creates remote case sessions through ssh metadata', async () => {
+ const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
+ try {
+ remoteStore.hosts = [
+ {
+ id: 'gpu-box',
+ label: 'GPU Box',
+ host: '10.0.0.42',
+ username: 'ubuntu',
+ commands: { codex: 'exec codx personal' },
+ },
+ ];
+ remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
+
+ const res = await harness.app.inject({
+ method: 'POST',
+ url: '/api/quick-start',
+ payload: { caseName: 'gpu-work', mode: 'shell' },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.success).toBe(true);
+ expect(body.data.casePath).toBe('/home/ubuntu/work');
+ const session = [...harness.ctx.sessions.values()].find((item) => item.id === body.data.sessionId);
+ expect(session?.toState()).toMatchObject({
+ workingDir: '/home/ubuntu/work',
+ remote: expect.objectContaining({
+ hostId: 'gpu-box',
+ host: '10.0.0.42',
+ username: 'ubuntu',
+ remotePath: '/home/ubuntu/work',
+ }),
+ });
+ } finally {
+ startShell.mockRestore();
+ }
+ });
+
it('creates session with valid resumeSessionId', async () => {
const res = await harness.app.inject({
method: 'POST',
diff --git a/test/session-attachment-history.test.ts b/test/session-attachment-history.test.ts
index c93b009e..bbd5a71d 100644
--- a/test/session-attachment-history.test.ts
+++ b/test/session-attachment-history.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { Session } from '../src/session.js';
+import { resolveMuxAttachCwd, Session } from '../src/session.js';
import type { SessionAttachmentHistoryItem } from '../src/types/session.js';
import {
ATTACHMENT_HISTORY_LIMIT,
@@ -161,4 +161,17 @@ describe('session attachment history', () => {
expect(persisted).toHaveLength(1);
expect(persisted?.[0].fileName).toBe('ok.png');
});
+
+ it('attaches remote mux sessions from a local cwd', () => {
+ expect(
+ resolveMuxAttachCwd('/Users/remote/project', {
+ hostId: 'mac-mini',
+ label: 'Mac Mini',
+ host: '192.168.21.109',
+ username: 'saqebakhter',
+ remotePath: '/Users/remote/project',
+ })
+ ).toBe('/tmp');
+ expect(resolveMuxAttachCwd('/opt/projects/Codeman')).toBe('/opt/projects/Codeman');
+ });
});
diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts
index 2fc27c3c..3b30c0c1 100644
--- a/test/tmux-manager.test.ts
+++ b/test/tmux-manager.test.ts
@@ -8,7 +8,13 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { TmuxManager, formatPaneSnapshot, parsePaneList, resolveActivePaneTarget } from '../src/tmux-manager.js';
+import {
+ TmuxManager,
+ buildRemoteLaunchCommand,
+ formatPaneSnapshot,
+ parsePaneList,
+ resolveActivePaneTarget,
+} from '../src/tmux-manager.js';
import { execSync, exec } from 'node:child_process';
// ============================================================================
@@ -91,6 +97,44 @@ describe('TmuxManager (unit)', () => {
});
});
+ describe('remote launch command builder', () => {
+ it('wraps codex command overrides in ssh with remote cd', () => {
+ const command = buildRemoteLaunchCommand({
+ mode: 'codex',
+ remote: {
+ hostId: 'gpu-box',
+ label: 'GPU Box',
+ host: '10.0.0.42',
+ username: 'ubuntu',
+ remotePath: '/home/ubuntu/work',
+ commands: { codex: 'exec codx personal' },
+ },
+ });
+
+ expect(command).toContain('ssh');
+ expect(command).toContain('BatchMode=yes');
+ expect(command).toContain('ubuntu@10.0.0.42');
+ expect(command).toContain('/home/ubuntu/work');
+ expect(command).toContain('bash -lc');
+ expect(command).toContain('exec codx personal');
+ });
+
+ it('uses default shell command when no override is configured', () => {
+ const command = buildRemoteLaunchCommand({
+ mode: 'shell',
+ remote: {
+ hostId: 'gpu-box',
+ label: 'GPU Box',
+ host: '10.0.0.42',
+ username: 'ubuntu',
+ remotePath: '/home/ubuntu/work',
+ },
+ });
+
+ expect(command).toContain('exec bash -l');
+ });
+ });
+
describe('getAttachCommand', () => {
it('should return tmux', () => {
expect(manager.getAttachCommand()).toBe('tmux');