From b00a680d42dc5cbe95703cb6578ef6da85bef62d Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 6 Jun 2026 03:53:54 +0200 Subject: [PATCH 1/3] feat(web): session detach/undock + beta instance isolation (port 5000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detach a session tab into its own browser window and back. Detach/undock: - GET /session/:id serves the SPA in "solo mode", reusing the existing client (terminal, local-echo overlay, reconnect) so no terminal code is duplicated. One PTY already fans out to N SSE/WS clients, so a detached window is just another live client — no server fan-out work was needed. - A pop-out icon per tab; detached tabs show a badge and focus the popup on click; closing the popup re-docks. Cross-window state via BroadcastChannel plus a WindowProxy poll, and survives a dashboard reload (roll-call). app.detachSession(id) is a single idempotent entry point (future gesture hook). so relative assets resolve under /session/:id. Beta-branch isolation (so it can run alongside a prod Codeman): - Default port 3000 -> 5000. - New src/config/instance.ts derives the data dir and tmux socket from CODEMAN_INSTANCE (default "beta"): ~/.codeman-beta + tmux -L codeman-beta. Every ~/.codeman path now goes through dataPath()/getDataDir() (state, mux-sessions, settings, push keys, lifecycle log, screenshots, certs, linked-cases, subagent window state). Overridable via CODEMAN_INSTANCE / CODEMAN_DATA_DIR / CODEMAN_TMUX_SOCKET. Prevents a second instance from discovering and attaching PTYs to the first instance's live tmux sessions. Verified: tsc / eslint / prettier / lockfile clean; Playwright E2E (27 checks) for detach/solo/redock; default isolation confirmed to see zero real sessions. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 3 +- src/cli.ts | 2 +- src/config/instance.ts | 60 +++++++++ src/push-store.ts | 4 +- src/session-lifecycle-log.ts | 6 +- src/state-store.ts | 3 +- src/tmux-manager.ts | 11 +- src/web/public/app.js | 223 ++++++++++++++++++++++++++++++- src/web/public/index.html | 8 ++ src/web/public/styles.css | 90 +++++++++++++ src/web/route-helpers.ts | 6 +- src/web/routes/case-routes.ts | 7 +- src/web/routes/session-routes.ts | 3 +- src/web/routes/system-routes.ts | 9 +- src/web/server.ts | 43 ++++-- 15 files changed, 444 insertions(+), 34 deletions(-) create mode 100644 src/config/instance.ts 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..faad0710 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 } from './config/instance.js'; /** Debounce delay for batching state writes (ms) */ const SAVE_DEBOUNCE_MS = 500; @@ -105,7 +106,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..a1144ec6 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -295,6 +295,18 @@ 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._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 +556,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 +793,180 @@ 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 out and still open → just raise it (fast path). + const existing = this.detachedWindows.get(id); + if (existing && !existing.closed) { try { existing.focus(); } catch {} 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 {} + } + + /** 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.detachedWindows.delete(id); + this._markDetached(id, false); + } + + /** 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). + this._postWindowMessage({ type: 'roll-call' }); + } + } + + _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._markDetached(msg.id, true); + } else if (msg.type === 'redocked' && msg.id) { + this._redock(msg.id); + } else if (msg.type === 'detach-request' && msg.id) { + // Future gesture hook: another window asks the dashboard to detach a tab. + this.detachSession(msg.id); + } + } + + /** 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 +1120,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 +2146,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 +2387,21 @@ class CodemanApp { const tallTabsEnabled = this._tallTabsEnabled ?? false; const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name; - parts.push(`