diff --git a/CLAUDE.md b/CLAUDE.md index d403ef53..412d545e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ## Additional Commands -`npm run dev` = dev server. Default port: `3000`. Commands not in Quick Reference: +`npm run dev` = dev server. Default port: `5000` on this `beta/session-detach` branch (`3000` on master). Commands not in Quick Reference: | Task | Command | |------|---------| @@ -98,6 +98,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design - **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3 - **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`. +- **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-` + `-L codeman-`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **This `beta/session-detach` branch defaults to `CODEMAN_INSTANCE=beta` and port 5000** so it coexists with a prod Codeman out of the box. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`. **Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files. diff --git a/src/cli.ts b/src/cli.ts index 2c7f396f..c49b486a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -483,7 +483,7 @@ program program .command('web') .description('Start the web interface') - .option('-p, --port ', 'Port to listen on', '3000') + .option('-p, --port ', 'Port to listen on', '5000') .option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)') .option('--title-hostname ', 'Override the hostname shown in the browser title') .action(async (options) => { diff --git a/src/config/instance.ts b/src/config/instance.ts new file mode 100644 index 00000000..4a3b9dff --- /dev/null +++ b/src/config/instance.ts @@ -0,0 +1,60 @@ +/** + * @fileoverview Per-instance isolation: data directory + tmux socket. + * + * Codeman keeps all runtime state under `~/.codeman` and runs its tmux sessions + * on a dedicated socket (`tmux -L codeman`). Both are PROCESS-WIDE and SHARED by + * every Codeman instance on the machine — so a second instance pointed at the + * same socket will discover and attach to the first instance's live sessions, + * and two instances sharing `~/.codeman/state.json` will clobber each other. + * + * To let a beta build coexist with a production one, this module derives both + * the data dir and the tmux socket from a single "instance" name: + * - default (this branch): `beta` → `~/.codeman-beta` + `tmux -L codeman-beta` + * - `CODEMAN_INSTANCE=` (empty) → `~/.codeman` + `tmux -L codeman` (prod layout) + * - `CODEMAN_INSTANCE=foo` → `~/.codeman-foo` + `tmux -L codeman-foo` + * + * Individual overrides still win: `CODEMAN_DATA_DIR` (absolute data dir) and + * `CODEMAN_TMUX_SOCKET` (socket name, validated in tmux-manager). + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { mkdirSync } from 'node:fs'; + +/** + * Instance name. Empty string = production layout (`~/.codeman`, `-L codeman`). + * Defaults to `beta` on the beta/session-detach branch so it never collides + * with a production Codeman. Set `CODEMAN_INSTANCE=` (empty) to opt back into + * the production layout. + */ +export const CODEMAN_INSTANCE = process.env.CODEMAN_INSTANCE ?? 'beta'; + +const INSTANCE_SUFFIX = CODEMAN_INSTANCE ? `-${CODEMAN_INSTANCE}` : ''; + +/** Default tmux socket for this instance. `CODEMAN_TMUX_SOCKET` still overrides. */ +export const DEFAULT_TMUX_SOCKET = `codeman${INSTANCE_SUFFIX}`; + +let _ensured = false; + +/** + * Absolute path to this instance's data directory (created on first use). All + * persisted state (`state.json`, `mux-sessions.json`, settings, push keys, + * lifecycle log, screenshots, certs, …) lives here. + */ +export function getDataDir(): string { + const dir = process.env.CODEMAN_DATA_DIR || join(homedir(), `.codeman${INSTANCE_SUFFIX}`); + if (!_ensured) { + try { + mkdirSync(dir, { recursive: true }); + _ensured = true; + } catch { + /* best-effort; individual writers also mkdir as needed */ + } + } + return dir; +} + +/** Join one or more segments onto this instance's data directory. */ +export function dataPath(...segments: string[]): string { + return join(getDataDir(), ...segments); +} diff --git a/src/push-store.ts b/src/push-store.ts index 3b610838..5cee424f 100644 --- a/src/push-store.ts +++ b/src/push-store.ts @@ -8,12 +8,12 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import { homedir } from 'node:os'; import webpush from 'web-push'; import type { VapidKeys, PushSubscriptionRecord } from './types.js'; import { Debouncer } from './utils/index.js'; +import { getDataDir } from './config/instance.js'; -const DATA_DIR = join(homedir(), '.codeman'); +const DATA_DIR = getDataDir(); const KEYS_FILE = join(DATA_DIR, 'push-keys.json'); const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json'); const SAVE_DEBOUNCE_MS = 500; diff --git a/src/session-lifecycle-log.ts b/src/session-lifecycle-log.ts index cd33ceb3..8cffa1ba 100644 --- a/src/session-lifecycle-log.ts +++ b/src/session-lifecycle-log.ts @@ -10,9 +10,9 @@ import { appendFile, readFile, writeFile } from 'node:fs/promises'; import { existsSync, mkdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { homedir } from 'node:os'; +import { dirname } from 'node:path'; import type { LifecycleEventType, LifecycleEntry } from './types.js'; +import { dataPath } from './config/instance.js'; const MAX_LINES = 10_000; const TRIM_TO = 8_000; @@ -22,7 +22,7 @@ export class SessionLifecycleLog { private writeQueue: Promise = Promise.resolve(); constructor(filePath?: string) { - this.filePath = filePath || join(homedir(), '.codeman', 'session-lifecycle.jsonl'); + this.filePath = filePath || dataPath('session-lifecycle.jsonl'); const dir = dirname(this.filePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }); diff --git a/src/state-store.ts b/src/state-store.ts index 0280d030..ffab99f4 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -39,6 +39,7 @@ import { TokenUsageEntry, } from './types.js'; import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js'; +import { dataPath, CODEMAN_INSTANCE } from './config/instance.js'; /** Debounce delay for batching state writes (ms) */ const SAVE_DEBOUNCE_MS = 500; @@ -89,8 +90,10 @@ export class StateStore { private _saveInFlight: Promise | null = null; constructor(filePath?: string) { - // Migrate legacy data directory (~/.claudeman → ~/.codeman) - if (!filePath) { + // Migrate legacy data directory (~/.claudeman → ~/.codeman). Default (prod) + // instance only — a named instance (e.g. beta) must never touch the shared + // ~/.codeman / ~/codeman-cases layout, preserving instance isolation. + if (!filePath && !CODEMAN_INSTANCE) { const legacyDir = join(homedir(), '.claudeman'); const newDir = join(homedir(), '.codeman'); if (existsSync(legacyDir) && !existsSync(newDir)) { @@ -105,7 +108,7 @@ export class StateStore { } } - this.filePath = filePath || join(homedir(), '.codeman', 'state.json'); + this.filePath = filePath || dataPath('state.json'); this.ralphStatePath = this.filePath.replace('.json', '-inner.json'); this.state = this.load(); this.state.config.stateFilePath = this.filePath; diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 9284e174..f48cd989 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -28,8 +28,8 @@ import { promisify } from 'node:util'; const execAsync = promisify(exec); import { existsSync, readFileSync, mkdirSync } from 'node:fs'; import { writeFile, rename } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { homedir } from 'node:os'; +import { dirname } from 'node:path'; +import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js'; import { ProcessStats, PersistedRespawnConfig, @@ -90,7 +90,7 @@ export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646; const IS_TEST_MODE = !!process.env.VITEST; /** Path to persisted mux session metadata */ -const MUX_SESSIONS_FILE = join(homedir(), '.codeman', 'mux-sessions.json'); +const MUX_SESSIONS_FILE = dataPath('mux-sessions.json'); /** Regex to validate tmux session names (only allow safe characters) */ const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/; @@ -101,8 +101,9 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/; /** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */ const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/; -/** Dedicated tmux socket for new Codeman-owned sessions. */ -const DEFAULT_CODEMAN_TMUX_SOCKET = 'codeman'; +/** Dedicated tmux socket for new Codeman-owned sessions (instance-scoped: + * `codeman` for prod, `codeman-beta` on the beta branch). */ +const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET; /** Regex to validate tmux socket names passed to `tmux -L`. */ const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/; diff --git a/src/web/public/app.js b/src/web/public/app.js index 1c535ddb..c73e910b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -295,6 +295,21 @@ class CodemanApp { this.terminal = null; this.fitAddon = null; this.activeSessionId = null; + + // ── Session detach / undock (beta) ─────────────────────────────────── + // A "solo window" is a popped-out browser window showing exactly one + // session. Detected from the /session/:id URL path (robust even if a cached + // service-worker shell loads), with the server-injected global as a fallback. + this.soloSessionId = this._detectSoloSessionId(); + this.isSoloWindow = !!this.soloSessionId; + this.detachedSessions = new Set(); // dashboard-side: ids currently popped out + this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy + this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle + this.windowChannel = null; // BroadcastChannel for cross-window sync + this._redockGrace = new Map(); // id -> timer: deferred redock (debounces popup reloads) + this._detachPingPending = null; // Set of ids awaiting a liveness answer + this._detachLivenessTimer = null; // periodic reconcile of channel-only detached windows + this._initGeneration = 0; // dedup concurrent handleInit calls this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive this._selectGeneration = 0; // cancel stale selectSession loads @@ -544,6 +559,11 @@ class CodemanApp { init() { // Initialize mobile detection first (adds device classes to body) MobileDetection.init(); + // Detach/undock: open the cross-window sync channel; if this is a solo + // (popped-out) window, apply its minimal chrome immediately so the tab + // strip never flashes before handleInit selects the target session. + this._initWindowChannel(); + if (this.isSoloWindow) document.body.classList.add('solo-mode'); // Initialize mobile handlers KeyboardHandler.init(); SwipeHandler.init(); @@ -776,6 +796,246 @@ class CodemanApp { } catch { /* non-fatal */ } } + // ══════════════════════════════════════════════════════════════════════ + // Session detach / undock (beta/session-detach) + // + // Each detached window is just another normal client of the same session: + // the server already fans one PTY's output out to N SSE/WS clients and merges + // input from all of them, so a popped-out window is live with no extra server + // plumbing. The dashboard tracks which sessions are out, marks their tabs, and + // re-docks when the window closes. A BroadcastChannel keeps state in sync + // across windows (and survives a dashboard reload via roll-call). + // ══════════════════════════════════════════════════════════════════════ + + /** Resolve the solo session id from the URL path (preferred) or the + * server-injected global (fallback). Returns null for the normal dashboard. */ + _detectSoloSessionId() { + try { + if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) { + return window.__CODEMAN_SOLO__; + } + const m = location.pathname.match(/^\/session\/([^/]+)\/?$/); + return m ? decodeURIComponent(m[1]) : null; + } catch { return null; } + } + + /** + * Pop a session out into its own browser window. SINGLE, idempotent entry + * point: the tab's pop-out icon calls this, and a future gesture layer + * ("pinch to drop") calls the exact same method — so keep it cheap and + * side-effect-light. Calling it again for an already-open window just raises + * that window. + * @param {string} id session id + */ + detachSession(id) { + if (this.isSoloWindow) return; // a solo window can't spawn more + if (!this.sessions.has(id)) return; + // Already detached → raise the existing popup instead of opening (or + // reloading) another. Mirrors the tab-click path: after a dashboard reload + // we hold no WindowProxy ref, so this raises via the channel rather than + // re-running window.open (which would reload the popup's terminal). Returns + // false only when we owned a now-closed window (re-dock + fall through to + // genuinely re-open below). + if (this.detachedSessions.has(id) && this._raiseDetached(id)) return; + const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no'; + let win = null; + try { win = window.open('/session/' + encodeURIComponent(id), 'codeman-session-' + id, features); } catch {} + if (!win) { + this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error'); + return; + } + this.detachedWindows.set(id, win); + this._markDetached(id, true); + this._watchDetachedWindow(id, win); + this._postWindowMessage({ type: 'detached', id }); + try { win.focus(); } catch {} + } + + /** Raise the popup for an already-detached session. Returns true if the raise + * was handled (caller should stop); false if we owned a now-closed window and + * re-docked it (caller should fall through to inline / re-open). Unifies the + * pop-out icon and tab-click paths so neither reloads a live popup. */ + _raiseDetached(id) { + const win = this.detachedWindows.get(id); + if (win && !win.closed) { try { win.focus(); } catch {} return true; } + if (win && win.closed) { this._redock(id); return false; } // owned ref dead → redock + fall through + // No local ref (dashboard reloaded): assume alive and raise via the channel. + // A liveness ping (or the popup's own unload) heals the badge if it's gone. + this._postWindowMessage({ type: 'focus-request', id }); + return true; + } + + /** Re-dock a session: close its window (which re-docks via its unload + * announcement) and clear dashboard state now. */ + redockSession(id) { + const win = this.detachedWindows.get(id); + if (win && !win.closed) { try { win.close(); } catch {} } + this._postWindowMessage({ type: 'close-request', id }); + this._redock(id); + } + + /** Clear all dashboard-side detached state/timers for a session. */ + _redock(id) { + const t = this._detachWatchTimers.get(id); + if (t) { clearInterval(t); this._detachWatchTimers.delete(id); } + this._cancelPendingRedock(id); + this.detachedWindows.delete(id); + this._markDetached(id, false); + } + + /** Defer a channel-driven redock briefly. A popup *reload* emits 'redocked' + * then re-announces 'detached'; the grace window lets that re-announce cancel + * the redock, so a reload doesn't blip the dashboard badge. A real close + * leaves the redock unanswered and it fires. */ + _scheduleRedock(id) { + if (this._redockGrace.has(id)) return; + const timer = setTimeout(() => { this._redockGrace.delete(id); this._redock(id); }, 1500); + this._redockGrace.set(id, timer); + } + + _cancelPendingRedock(id) { + const t = this._redockGrace.get(id); + if (t) { clearTimeout(t); this._redockGrace.delete(id); } + } + + /** Toggle the "detached" marker on a tab (immediate DOM update + state set). + * Full re-renders re-apply the class from this.detachedSessions. */ + _markDetached(id, on) { + if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id); + const container = this.$('sessionTabs'); + const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`); + if (tab) tab.classList.toggle('detached', on); + } + + /** Poll a window we opened; when it closes, re-dock its tab. This is the + * primary (reliable) close-detection path for windows this tab opened. */ + _watchDetachedWindow(id, win) { + const prev = this._detachWatchTimers.get(id); + if (prev) clearInterval(prev); + const timer = setInterval(() => { + if (!win || win.closed) { + clearInterval(timer); + this._detachWatchTimers.delete(id); + this._redock(id); + } + }, 800); + this._detachWatchTimers.set(id, timer); + } + + /** Open the cross-window BroadcastChannel and wire role-specific handlers. */ + _initWindowChannel() { + if (typeof BroadcastChannel === 'undefined') return; + try { this.windowChannel = new BroadcastChannel('codeman-windows'); } + catch { this.windowChannel = null; return; } + this.windowChannel.onmessage = (e) => this._onWindowMessage(e.data); + if (this.isSoloWindow) { + // Announce presence so the dashboard marks this session's tab detached — + // even if this window was opened directly by URL rather than window.open. + this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); + // On close, tell the dashboard to re-dock. pagehide is the reliable signal + // on modern browsers; beforeunload is a belt-and-suspenders fallback. + const announceClose = () => this._postWindowMessage({ type: 'redocked', id: this.soloSessionId }); + window.addEventListener('pagehide', announceClose); + window.addEventListener('beforeunload', announceClose); + } else { + // Dashboard: ask any already-open solo windows to re-announce themselves + // (covers a dashboard reload while popups remain open), then keep + // reconciling so a popup that died WITHOUT a 'redocked' (hard kill / crash) + // eventually un-marks its tab. + this._postWindowMessage({ type: 'roll-call' }); + this._startDetachLiveness(); + } + } + + _postWindowMessage(msg) { + try { if (this.windowChannel) this.windowChannel.postMessage(msg); } catch {} + } + + _onWindowMessage(msg) { + if (!msg || typeof msg !== 'object') return; + if (this.isSoloWindow) { + // Roll-call has no id (broadcast to all) — answer before the id filter. + if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; } + if (msg.id !== this.soloSessionId) return; + if (msg.type === 'close-request') { try { window.close(); } catch {} } + else if (msg.type === 'focus-request') { try { window.focus(); } catch {} } + return; + } + // Dashboard side. + if (msg.type === 'detached' && msg.id) { + this._cancelPendingRedock(msg.id); // a re-announce (e.g. popup reload) cancels a deferred redock + this._detachPingPending?.delete(msg.id); // and proves liveness for this tick + this._markDetached(msg.id, true); + } else if (msg.type === 'redocked' && msg.id) { + this._scheduleRedock(msg.id); // defer: a popup reload fires redocked→detached; grace avoids a badge blip + } else if (msg.type === 'detach-request' && msg.id) { + // Future gesture hook: another window asks the dashboard to detach a tab. + this.detachSession(msg.id); + } + } + + /** Dashboard: periodically reconcile detached tabs we hold no window ref for + * (e.g. after a dashboard reload). Owned windows are covered by the + * win.closed poll; channel-only ones can only be checked by asking them to + * re-announce and re-docking any that stay silent. */ + _startDetachLiveness() { + if (this._detachLivenessTimer) return; + this._detachLivenessTimer = setInterval(() => this._pingDetached(), 5000); + } + + _pingDetached() { + const orphans = []; + for (const id of this.detachedSessions) { + const win = this.detachedWindows.get(id); + if (!win) orphans.push(id); // channel-only — must verify via re-announce + else if (win.closed) this._redock(id); // owned & closed — heal now + } + if (!orphans.length) return; + this._detachPingPending = new Set(orphans); + this._postWindowMessage({ type: 'roll-call' }); + // Live popups answer 'detached' (clearing themselves above); survivors are gone. + setTimeout(() => { + if (!this._detachPingPending) return; + for (const id of this._detachPingPending) this._redock(id); + this._detachPingPending = null; + }, 1200); + } + + /** Solo window: select the target session and apply minimal single-session + * chrome. Called from handleInit once the session list has loaded. */ + _applySoloMode() { + document.body.classList.add('solo-mode'); + const session = this.sessions.get(this.soloSessionId); + if (!session) { this._showSoloSessionGone(); return; } + // Force re-select (handleInit cleared terminal state above). + this.activeSessionId = null; + this.selectSession(this.soloSessionId); + const name = this.getSessionName(session) || 'Session'; + const titleEl = document.getElementById('soloSessionTitle'); + if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; } + const redock = document.getElementById('soloRedockBtn'); + if (redock) redock.style.display = ''; + document.title = name + ' — Codeman'; + if (this.notificationManager) this.notificationManager.originalTitle = document.title; + // Neutralize the dashboard-only brand click in a solo window. + const logo = document.querySelector('.header-brand .logo'); + if (logo) logo.onclick = (e) => { e.preventDefault(); }; + } + + /** Solo window: the target session is gone (never existed, or ended while + * this window was open). Show a friendly terminal state. */ + _showSoloSessionGone() { + document.body.classList.add('solo-mode'); + if (document.querySelector('.solo-gone-overlay')) return; + const el = document.createElement('div'); + el.className = 'solo-gone-overlay'; + el.innerHTML = '

Session unavailable

' + + '

This session has ended or is no longer available.

' + + ''; + document.body.appendChild(el); + document.title = 'Session ended — Codeman'; + } + connectSSE() { // Check if browser is offline if (!navigator.onLine) { @@ -929,6 +1189,12 @@ class CodemanApp { _onSessionDeleted(data) { if (this._wsSessionId === data.id) this._disconnectWs(); + // Solo window whose session just ended → show the "unavailable" state. + if (this.isSoloWindow && data.id === this.soloSessionId) { + this._showSoloSessionGone(); + } + // Dashboard: a detached session ended → clear its detached state/timers. + if (this.detachedSessions.has(data.id)) this._redock(data.id); this._cleanupSessionData(data.id); if (this.activeSessionId === data.id) { this.activeSessionId = null; @@ -1949,6 +2215,14 @@ class CodemanApp { // Reset activeSessionId so selectSession doesn't early-return. // Guard: skip if a newer handleInit has already started (race between loadState + SSE init). if (gen !== this._initGeneration) return; + + // Solo (detached) window: always show exactly the target session, ignoring + // the dashboard's "restore last active" logic. + if (this.isSoloWindow) { + this._applySoloMode(); + return; + } + const previousActiveId = this.activeSessionId; this.activeSessionId = null; if (this.sessionOrder.length > 0) { @@ -2182,19 +2456,21 @@ class CodemanApp { const tallTabsEnabled = this._tallTabsEnabled ?? false; const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name; - parts.push(`