From 3bf991d73055f8144f44deab91cf1999c28af535 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Mon, 8 Jun 2026 10:37:20 +0000 Subject: [PATCH 1/8] feat: add remote host case domain --- src/remote-hosts.ts | 80 +++++++++++++++++++++++++++++++++++++++ src/types/session.ts | 30 +++++++++++++++ test/remote-hosts.test.ts | 66 ++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 src/remote-hosts.ts create mode 100644 test/remote-hosts.test.ts diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts new file mode 100644 index 00000000..1272beea --- /dev/null +++ b/src/remote-hosts.ts @@ -0,0 +1,80 @@ +import { existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { join } from 'node:path'; +import type { RemoteCase, RemoteCommandMode, RemoteHost, SessionMode, SessionRemote } from './types.js'; + +const REMOTE_HOSTS_FILE = 'remote-hosts.json'; +const REMOTE_CASES_FILE = 'remote-cases.json'; + +export function remoteHostsPath(configDir: string): string { + return join(configDir, REMOTE_HOSTS_FILE); +} + +export function remoteCasesPath(configDir: string): string { + return join(configDir, REMOTE_CASES_FILE); +} + +async function readJsonArray(path: string): Promise { + try { + const raw = await fs.readFile(path, 'utf-8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } +} + +async function writeJsonArray(configDir: string, path: string, value: T[]): Promise { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await fs.writeFile(path, JSON.stringify(value, null, 2)); +} + +export async function readRemoteHosts(configDir: string): Promise { + return readJsonArray(remoteHostsPath(configDir)); +} + +export async function writeRemoteHosts(configDir: string, hosts: RemoteHost[]): Promise { + await writeJsonArray(configDir, remoteHostsPath(configDir), hosts); +} + +export async function readRemoteCases(configDir: string): Promise { + return readJsonArray(remoteCasesPath(configDir)); +} + +export async function writeRemoteCases(configDir: string, cases: RemoteCase[]): Promise { + await writeJsonArray(configDir, remoteCasesPath(configDir), cases); +} + +export function defaultRemoteCommandForMode(mode: SessionMode): string { + const commands: Record = { + shell: 'exec bash -l', + claude: 'exec claude', + opencode: 'exec opencode', + codex: 'exec codex', + gemini: 'exec gemini', + }; + return commands[mode as RemoteCommandMode] || commands.shell; +} + +export function remoteSshTarget(host: Pick): string { + return `${host.username}@${host.host}`; +} + +export function remoteDisplayPath( + remote: Pick | { username: string; host: string; path: string } +): string { + const path = 'remotePath' in remote ? remote.remotePath : remote.path; + return `${remote.username}@${remote.host}:${path}`; +} + +export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote { + return { + hostId: host.id, + label: host.label, + host: host.host, + username: host.username, + port: host.port, + remotePath: remoteCase.remotePath, + commands: host.commands, + }; +} diff --git a/src/types/session.ts b/src/types/session.ts index 460f6e67..c10f7b27 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -43,6 +43,34 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedToo /** Session mode: which CLI backend a session runs */ export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'; +export type RemoteCommandMode = Extract; + +export interface RemoteHost { + id: string; + label: string; + host: string; + username: string; + port?: number; + commands?: Partial>; +} + +export interface RemoteCase { + name: string; + type: 'remote'; + hostId: string; + remotePath: string; +} + +export interface SessionRemote { + hostId: string; + label: string; + host: string; + username: string; + port?: number; + remotePath: string; + commands?: Partial>; +} + /** * Valid Claude CLI effort levels (claude >= 2.1.154). * `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a @@ -160,6 +188,8 @@ export interface SessionState { status: SessionStatus; /** Working directory path */ workingDir: string; + /** Remote execution metadata, present when this session runs over SSH through local tmux */ + remote?: SessionRemote; /** ID of currently assigned task, null if none */ currentTaskId: string | null; /** Timestamp when session was created */ diff --git a/test/remote-hosts.test.ts b/test/remote-hosts.test.ts new file mode 100644 index 00000000..05ce917d --- /dev/null +++ b/test/remote-hosts.test.ts @@ -0,0 +1,66 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + defaultRemoteCommandForMode, + readRemoteCases, + readRemoteHosts, + remoteDisplayPath, + remoteSshTarget, + writeRemoteCases, + writeRemoteHosts, +} from '../src/remote-hosts.js'; + +describe('remote-hosts domain', () => { + let dir: string | null = null; + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = null; + }); + + function configDir(): string { + dir = mkdtempSync(join(tmpdir(), 'codeman-remote-hosts-')); + return dir; + } + + it('round-trips remote hosts and remote cases from a config directory', async () => { + const root = configDir(); + await writeRemoteHosts(root, [ + { + id: 'gpu-box', + label: 'GPU Box', + host: '10.0.0.42', + username: 'ubuntu', + commands: { codex: 'exec codx personal' }, + }, + ]); + await writeRemoteCases(root, [ + { name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }, + ]); + + await expect(readRemoteHosts(root)).resolves.toEqual([ + { + id: 'gpu-box', + label: 'GPU Box', + host: '10.0.0.42', + username: 'ubuntu', + commands: { codex: 'exec codx personal' }, + }, + ]); + await expect(readRemoteCases(root)).resolves.toEqual([ + { name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }, + ]); + }); + + it('returns safe mode defaults and remote display values', () => { + expect(defaultRemoteCommandForMode('shell')).toBe('exec bash -l'); + expect(defaultRemoteCommandForMode('codex')).toBe('exec codex'); + expect(defaultRemoteCommandForMode('claude')).toBe('exec claude'); + expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local'); + expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe( + 'aamer@box.local:/opt/work' + ); + }); +}); From 568d93efb0c80c823fc204d23dd0552407729c8e Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Mon, 8 Jun 2026 10:40:56 +0000 Subject: [PATCH 2/8] feat: add remote host case routes --- src/types/api.ts | 12 ++++ src/web/routes/case-routes.ts | 110 +++++++++++++++++++++++++++++++- src/web/schemas.ts | 34 ++++++++++ test/routes/case-routes.test.ts | 86 +++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 1 deletion(-) diff --git a/src/types/api.ts b/src/types/api.ts index 23a97594..1e03a3d6 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -123,6 +123,18 @@ export interface CaseInfo { path: string; /** Whether CLAUDE.md exists */ hasClaudeMd?: boolean; + /** Case storage/execution location */ + location?: 'local' | 'linked-local' | 'remote'; + /** Whether this is a linked local folder */ + linked?: boolean; + /** Remote case metadata for display and session creation */ + remote?: { + hostId: string; + hostLabel: string; + host: string; + username: string; + path: string; + }; } // ========== Error Handling Utilities ========== diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 48ab869f..35b9fa82 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -11,15 +11,29 @@ import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; import type { ApiResponse, CaseInfo } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; -import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js'; +import { + CreateCaseSchema, + LinkCaseSchema, + CaseOrderSchema, + RemoteCaseLinkSchema, + RemoteHostSchema, +} from '../schemas.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; import { dataPath, getDataDir } from '../../config/instance.js'; +import { + readRemoteCases, + readRemoteHosts, + remoteDisplayPath, + writeRemoteCases, + writeRemoteHosts, +} from '../../remote-hosts.js'; const LINKED_CASES_FILE = dataPath('linked-cases.json'); +const CODEMAN_CONFIG_DIR = getDataDir(); const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/; /** Read and parse linked-cases.json, returning empty object on missing/invalid file. */ @@ -53,6 +67,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config name: e.name, path: join(CASES_DIR, e.name), hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')), + location: 'local', }); } } @@ -69,10 +84,34 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config name, path, hasClaudeMd: existsSync(join(path, 'CLAUDE.md')), + linked: true, + location: 'linked-local', }); } } + // Get remote cases + const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); + 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; + existingNames.add(remoteCase.name); + cases.push({ + 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, + }, + }); + } + // Sort by persisted caseOrder from settings.json const settings = await readJsonConfig>(SETTINGS_PATH, 'settings', {}); const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : []; @@ -120,6 +159,65 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } }); + app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR)); + + app.post('/api/remote-hosts', async (req): Promise> => { + const host = parseBody(RemoteHostSchema, req.body); + const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); + if (hosts.some((item) => item.id === host.id)) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Remote host already exists'); + } + await writeRemoteHosts(CODEMAN_CONFIG_DIR, [...hosts, host]); + return { success: true, data: { host } }; + }); + + app.put('/api/remote-hosts/:id', async (req): Promise> => { + const { id } = req.params as { id: string }; + const host = parseBody(RemoteHostSchema, { ...(req.body as object), id }); + const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); + const index = hosts.findIndex((item) => item.id === id); + if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); + const next = [...hosts]; + next[index] = host; + await writeRemoteHosts(CODEMAN_CONFIG_DIR, next); + return { success: true, data: { host } }; + }); + + app.delete('/api/remote-hosts/:id', async (req): Promise> => { + const { id } = req.params as { id: string }; + const cases = await readRemoteCases(CODEMAN_CONFIG_DIR); + if (cases.some((item) => item.hostId === id)) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Remote host is still used by remote cases'); + } + const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); + await writeRemoteHosts( + CODEMAN_CONFIG_DIR, + hosts.filter((item) => item.id !== id) + ); + return { success: true, data: { id } }; + }); + + app.post('/api/cases/remote-link', async (req): Promise> => { + const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const }; + const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); + const host = hosts.find((item) => item.id === remoteCase.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); + + const linkedCases = await readLinkedCases(); + const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); + if ( + remoteCases.some((item) => item.name === remoteCase.name) || + linkedCases[remoteCase.name] || + existsSync(join(CASES_DIR, remoteCase.name)) + ) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); + } + + await writeRemoteCases(CODEMAN_CONFIG_DIR, [...remoteCases, remoteCase]); + ctx.broadcast(SseEvent.CaseLinked, { name: remoteCase.name, path: remoteCase.remotePath, type: 'remote' }); + return { success: true, data: { case: remoteCase } }; + }); + // Link an existing folder as a case app.post('/api/cases/link', async (req): Promise> => { const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body'); @@ -173,6 +271,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } + const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); + if (remoteCases.some((item) => item.name === name)) { + await writeRemoteCases( + CODEMAN_CONFIG_DIR, + remoteCases.filter((item) => item.name !== name) + ); + ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' }); + return { success: true, data: { name } }; + } + // Check linked cases first — unlink only, don't delete the actual directory const linkedCases = await readLinkedCases(); if (linkedCases[name]) { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 45aeddd4..f3a82e63 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -271,6 +271,40 @@ export const CreateCaseSchema = z.object({ description: z.string().max(1000).optional(), }); +const RemoteCommandOverridesSchema = z + .object({ + shell: z.string().min(1).max(300).optional(), + claude: z.string().min(1).max(300).optional(), + opencode: z.string().min(1).max(300).optional(), + codex: z.string().min(1).max(300).optional(), + gemini: z.string().min(1).max(300).optional(), + }) + .strict() + .optional(); + +export const RemoteHostSchema = z.object({ + id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'), + label: z.string().min(1).max(100), + host: z + .string() + .min(1) + .max(255) + .regex(/^[a-zA-Z0-9._:-]+$/, 'Invalid SSH host'), + username: z + .string() + .min(1) + .max(100) + .regex(/^[a-zA-Z0-9._-]+$/, 'Invalid SSH username'), + port: z.number().int().min(1).max(65535).optional(), + commands: RemoteCommandOverridesSchema, +}); + +export const RemoteCaseLinkSchema = z.object({ + name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), + hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'), + remotePath: z.string().min(1).max(2000).regex(/^\//, 'Remote path must be absolute'), +}); + // ========== Quick Start ========== /** diff --git a/test/routes/case-routes.test.ts b/test/routes/case-routes.test.ts index ac1a60f5..2cc6fc21 100644 --- a/test/routes/case-routes.test.ts +++ b/test/routes/case-routes.test.ts @@ -62,6 +62,7 @@ const mockedMkdirSync = vi.mocked(mkdirSync); const mockedReaddirSync = vi.mocked(readdirSync); const mockedReaddir = vi.mocked(fs.readdir); const mockedReadFile = vi.mocked(fs.readFile); +const mockedWriteFile = vi.mocked(fs.writeFile); interface CaseRouteHarness { app: FastifyInstance; @@ -199,6 +200,91 @@ describe('case-routes', () => { }); }); + describe('remote host and remote case routes', () => { + function setupRemoteConfigStore() { + const store = new Map(); + mockedReadFile.mockImplementation(async (path) => { + const key = String(path); + if (store.has(key)) return store.get(key) || ''; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockedWriteFile.mockImplementation(async (path, data) => { + store.set(String(path), String(data)); + }); + } + + it('creates a remote host and lists it', async () => { + setupRemoteConfigStore(); + + const create = await harness.app.inject({ + method: 'POST', + url: '/api/remote-hosts', + payload: { + id: 'gpu-box', + label: 'GPU Box', + host: '10.0.0.42', + username: 'ubuntu', + commands: { codex: 'exec codx personal' }, + }, + }); + expect(create.statusCode).toBe(200); + expect(JSON.parse(create.body)).toMatchObject({ success: true }); + + const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' }); + expect(list.statusCode).toBe(200); + expect(JSON.parse(list.body)).toEqual([ + expect.objectContaining({ id: 'gpu-box', label: 'GPU Box', commands: { codex: 'exec codx personal' } }), + ]); + }); + + it('links a remote case and includes it in GET /api/cases', async () => { + setupRemoteConfigStore(); + mockedReaddir.mockRejectedValue(new Error('ENOENT')); + + await harness.app.inject({ + method: 'POST', + url: '/api/remote-hosts', + payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' }, + }); + + const link = await harness.app.inject({ + method: 'POST', + url: '/api/cases/remote-link', + payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }, + }); + expect(link.statusCode).toBe(200); + + const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' }); + expect(JSON.parse(cases.body)).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' }), + }) + ); + }); + + it('deletes remote case metadata only', async () => { + setupRemoteConfigStore(); + + 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' }, + }); + + const deleted = await harness.app.inject({ method: 'DELETE', url: '/api/cases/gpu-work' }); + expect(deleted.statusCode).toBe(200); + expect(JSON.parse(deleted.body)).toEqual({ success: true, data: { name: 'gpu-work' } }); + }); + }); + // ========== POST /api/cases ========== describe('POST /api/cases', () => { From 26e78daf5856743baa1e25064496a4ba5ef2e7ca Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Thu, 11 Jun 2026 18:43:39 +0000 Subject: [PATCH 3/8] fix: COD-24 stabilize remote host sessions --- src/session.ts | 18 +- src/tmux-manager.ts | 12 ++ src/types/api.ts | 1 - src/web/public/index.html | 30 +++ src/web/public/session-ui.js | 77 +++++++- src/web/routes/case-routes.ts | 32 +++- src/web/routes/session-routes.ts | 48 +++-- test/routes/case-routes.test.ts | 32 +++- test/routes/session-routes.test.ts | 231 ++++++++++++++++++++++++ test/session-attachment-history.test.ts | 15 +- test/tmux-manager.test.ts | 46 ++++- 11 files changed, 512 insertions(+), 30 deletions(-) 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 @@

Add Case

Absolute path to an existing project folder, e.g. /home/you/my-project + +