diff --git a/src/session.ts b/src/session.ts index a509040a..02ce3e1b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -50,7 +50,9 @@ import { type EffortLevel, type GeminiConfig, type SessionRemote, + type SessionDocker, } from './types.js'; +import { probeDockerCliVersion } from './docker-hosts.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; @@ -178,6 +180,8 @@ export function isAltScreenStripMode(mode: SessionMode): boolean { const DEFAULT_PTY_COLS = 120; const DEFAULT_PTY_ROWS = 40; const TMUX_DISPLAY_TIMEOUT_MS = 2000; +/** Delay before the in-container Claude CLI version probe (lets the container start). */ +const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000; /** * Ask tmux for the current window geometry of `muxName` so a re-attaching PTY @@ -212,8 +216,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; +export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote, docker?: SessionDocker): string { + // Remote and docker sessions run the CLI elsewhere (ssh / docker exec); the LOCAL + // wrapper pane never needs the workspace as its cwd, so launch it in /tmp. + return remote || docker ? '/tmp' : workingDir; } /** @@ -402,6 +408,10 @@ export class Session extends EventEmitter { // Remote execution metadata, present when this session runs over SSH through local tmux. private readonly _remote?: SessionRemote; + // Docker execution metadata, present when this session runs inside a container via + // local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions). + private readonly _docker?: SessionDocker; + // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; @@ -475,6 +485,8 @@ export class Session extends EventEmitter { attachmentHistory?: SessionAttachmentHistoryItem[]; /** Remote execution metadata for sessions launched through SSH inside local tmux. */ remote?: SessionRemote; + /** Docker execution metadata for sessions launched inside a container via local tmux. */ + docker?: SessionDocker; } ) { super(); @@ -548,6 +560,7 @@ export class Session extends EventEmitter { } this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; this._remote = config.remote; + this._docker = config.docker; if (config.attachmentHistory && config.attachmentHistory.length > 0) { this.restoreAttachmentHistory(config.attachmentHistory); } @@ -649,6 +662,11 @@ export class Session extends EventEmitter { return this._claudeSessionId; } + /** Docker execution metadata when this session runs inside a container, else undefined. */ + get docker(): SessionDocker | undefined { + return this._docker; + } + // Adopt a Claude conversation ID observed from an external source (e.g. hook // payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so // `_handleJsonMessage` never sees `session_id`; hooks are the only signal @@ -1008,6 +1026,7 @@ export class Session extends EventEmitter { status: this._status, workingDir: this.workingDir, remote: this._remote, + docker: this._docker, currentTaskId: this._currentTaskId, createdAt: this.createdAt, lastActivityAt: this._lastActivityAt, @@ -1197,7 +1216,7 @@ export class Session extends EventEmitter { name: 'xterm-256color', cols: ptyCols, rows: ptyRows, - cwd: resolveMuxAttachCwd(this.workingDir, this._remote), + cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker), // COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports() // in tmux-manager.ts so the attach client and the tmux session agree. env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'), @@ -1317,7 +1336,7 @@ export class Session extends EventEmitter { // repaint/alt-screen mode; issue #154). Remote sessions run claude on // another host, so a local probe wouldn't reflect their version — skip them // and let the banner scrape handle those. Cached process-wide, best-effort. - if (this.mode === 'claude' && !this._remote && !this._cliVersion) { + if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) { const probedVersion = getClaudeCliVersion(); if (probedVersion) { this._cliVersion = probedVersion; @@ -1330,6 +1349,31 @@ export class Session extends EventEmitter { } } + // Docker sessions run claude INSIDE the container, so the local probe above + // reports the HOST claude (wrong version, and leaving cliVersion undefined + // silently disables wheel-forwarding, #154). Probe the IN-CONTAINER version + // instead — deferred so the container is up after the mux attach below. + if (this.mode === 'claude' && this._docker && !this._cliVersion) { + const dockerMeta = this._docker; + setTimeout(() => { + if (this._isStopped || this._cliVersion) return; + void probeDockerCliVersion(dockerMeta, this.mode) + .then((version) => { + if (!version || this._isStopped || this._cliVersion) return; + this._cliVersion = version; + this.emit('cliInfoUpdated', { + version: this._cliVersion, + model: this._cliModel, + accountType: this._cliAccountType, + latestVersion: this._cliLatestVersion, + }); + }) + .catch(() => { + /* best-effort */ + }); + }, DOCKER_CLI_VERSION_PROBE_DELAY_MS); + } + // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { @@ -1350,6 +1394,7 @@ export class Session extends EventEmitter { effort: this._effort, historyLimit: this._tmuxHistoryLimit, remote: this._remote, + docker: this._docker, }, createSessionOptions: { sessionId: this.id, @@ -1368,6 +1413,7 @@ export class Session extends EventEmitter { effort: this._effort, historyLimit: this._tmuxHistoryLimit, remote: this._remote, + docker: this._docker, }, spawnErrLabel: 'mux attachment', }); @@ -1738,6 +1784,7 @@ export class Session extends EventEmitter { envOverrides: this._envOverrides, historyLimit: this._tmuxHistoryLimit, remote: this._remote, + docker: this._docker, }, createSessionOptions: { sessionId: this.id, @@ -1748,6 +1795,7 @@ export class Session extends EventEmitter { envOverrides: this._envOverrides, historyLimit: this._tmuxHistoryLimit, remote: this._remote, + docker: this._docker, }, spawnErrLabel: 'shell mux attachment', }); diff --git a/src/web/server.ts b/src/web/server.ts index 393b5a35..56e951ff 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2214,6 +2214,10 @@ export class WebServer extends EventEmitter { // erasing `remote` from state.json on the next persist. mux-sessions.json // round-trips MuxSession.remote; state.json carries SessionState.remote. remote: muxSession.remote ?? savedState?.remote, + // Docker metadata round-trips the same way (mux-sessions.json carries + // MuxSession.docker; state.json carries SessionState.docker), so recovery + // rebuilds the `docker exec` launch instead of a broken local command. + docker: muxSession.docker ?? savedState?.docker, }); // Update session name if it was a "Restored:" placeholder or doesn't match saved name