mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(web): session detach/undock + beta instance isolation (port 5000)
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). <base href="/"> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -72,7 +72,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
## Additional Commands
|
## 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 |
|
| 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
|
- **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
|
- **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`.
|
- **`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-<name>` + `-L codeman-<name>`), 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.
|
**Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -483,7 +483,7 @@ program
|
|||||||
program
|
program
|
||||||
.command('web')
|
.command('web')
|
||||||
.description('Start the web interface')
|
.description('Start the web interface')
|
||||||
.option('-p, --port <port>', 'Port to listen on', '3000')
|
.option('-p, --port <port>', 'Port to listen on', '5000')
|
||||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||||
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+2
-2
@@ -8,12 +8,12 @@
|
|||||||
|
|
||||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
|
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
|
||||||
import { Debouncer } from './utils/index.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 KEYS_FILE = join(DATA_DIR, 'push-keys.json');
|
||||||
const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json');
|
const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json');
|
||||||
const SAVE_DEBOUNCE_MS = 500;
|
const SAVE_DEBOUNCE_MS = 500;
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
|
|
||||||
import { appendFile, readFile, writeFile } from 'node:fs/promises';
|
import { appendFile, readFile, writeFile } from 'node:fs/promises';
|
||||||
import { existsSync, mkdirSync } from 'node:fs';
|
import { existsSync, mkdirSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import type { LifecycleEventType, LifecycleEntry } from './types.js';
|
import type { LifecycleEventType, LifecycleEntry } from './types.js';
|
||||||
|
import { dataPath } from './config/instance.js';
|
||||||
|
|
||||||
const MAX_LINES = 10_000;
|
const MAX_LINES = 10_000;
|
||||||
const TRIM_TO = 8_000;
|
const TRIM_TO = 8_000;
|
||||||
@@ -22,7 +22,7 @@ export class SessionLifecycleLog {
|
|||||||
private writeQueue: Promise<void> = Promise.resolve();
|
private writeQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
constructor(filePath?: string) {
|
constructor(filePath?: string) {
|
||||||
this.filePath = filePath || join(homedir(), '.codeman', 'session-lifecycle.jsonl');
|
this.filePath = filePath || dataPath('session-lifecycle.jsonl');
|
||||||
const dir = dirname(this.filePath);
|
const dir = dirname(this.filePath);
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
|||||||
+2
-1
@@ -39,6 +39,7 @@ import {
|
|||||||
TokenUsageEntry,
|
TokenUsageEntry,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js';
|
import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js';
|
||||||
|
import { dataPath } from './config/instance.js';
|
||||||
|
|
||||||
/** Debounce delay for batching state writes (ms) */
|
/** Debounce delay for batching state writes (ms) */
|
||||||
const SAVE_DEBOUNCE_MS = 500;
|
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.ralphStatePath = this.filePath.replace('.json', '-inner.json');
|
||||||
this.state = this.load();
|
this.state = this.load();
|
||||||
this.state.config.stateFilePath = this.filePath;
|
this.state.config.stateFilePath = this.filePath;
|
||||||
|
|||||||
+6
-5
@@ -28,8 +28,8 @@ import { promisify } from 'node:util';
|
|||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||||||
import { writeFile, rename } from 'node:fs/promises';
|
import { writeFile, rename } from 'node:fs/promises';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js';
|
||||||
import {
|
import {
|
||||||
ProcessStats,
|
ProcessStats,
|
||||||
PersistedRespawnConfig,
|
PersistedRespawnConfig,
|
||||||
@@ -90,7 +90,7 @@ export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
|
|||||||
const IS_TEST_MODE = !!process.env.VITEST;
|
const IS_TEST_MODE = !!process.env.VITEST;
|
||||||
|
|
||||||
/** Path to persisted mux session metadata */
|
/** 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) */
|
/** Regex to validate tmux session names (only allow safe characters) */
|
||||||
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
|
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") */
|
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
|
||||||
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
|
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
|
||||||
|
|
||||||
/** Dedicated tmux socket for new Codeman-owned sessions. */
|
/** Dedicated tmux socket for new Codeman-owned sessions (instance-scoped:
|
||||||
const DEFAULT_CODEMAN_TMUX_SOCKET = 'codeman';
|
* `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`. */
|
/** Regex to validate tmux socket names passed to `tmux -L`. */
|
||||||
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||||
|
|||||||
+222
-1
@@ -295,6 +295,18 @@ class CodemanApp {
|
|||||||
this.terminal = null;
|
this.terminal = null;
|
||||||
this.fitAddon = null;
|
this.fitAddon = null;
|
||||||
this.activeSessionId = 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._initGeneration = 0; // dedup concurrent handleInit calls
|
||||||
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
|
||||||
this._selectGeneration = 0; // cancel stale selectSession loads
|
this._selectGeneration = 0; // cancel stale selectSession loads
|
||||||
@@ -544,6 +556,11 @@ class CodemanApp {
|
|||||||
init() {
|
init() {
|
||||||
// Initialize mobile detection first (adds device classes to body)
|
// Initialize mobile detection first (adds device classes to body)
|
||||||
MobileDetection.init();
|
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
|
// Initialize mobile handlers
|
||||||
KeyboardHandler.init();
|
KeyboardHandler.init();
|
||||||
SwipeHandler.init();
|
SwipeHandler.init();
|
||||||
@@ -776,6 +793,180 @@ class CodemanApp {
|
|||||||
} catch { /* non-fatal */ }
|
} 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 = '<h2>Session unavailable</h2>'
|
||||||
|
+ '<p>This session has ended or is no longer available.</p>'
|
||||||
|
+ '<button class="btn-primary" onclick="window.close()">Close window</button>';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
document.title = 'Session ended — Codeman';
|
||||||
|
}
|
||||||
|
|
||||||
connectSSE() {
|
connectSSE() {
|
||||||
// Check if browser is offline
|
// Check if browser is offline
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
@@ -929,6 +1120,12 @@ class CodemanApp {
|
|||||||
|
|
||||||
_onSessionDeleted(data) {
|
_onSessionDeleted(data) {
|
||||||
if (this._wsSessionId === data.id) this._disconnectWs();
|
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);
|
this._cleanupSessionData(data.id);
|
||||||
if (this.activeSessionId === data.id) {
|
if (this.activeSessionId === data.id) {
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
@@ -1949,6 +2146,14 @@ class CodemanApp {
|
|||||||
// Reset activeSessionId so selectSession doesn't early-return.
|
// Reset activeSessionId so selectSession doesn't early-return.
|
||||||
// Guard: skip if a newer handleInit has already started (race between loadState + SSE init).
|
// Guard: skip if a newer handleInit has already started (race between loadState + SSE init).
|
||||||
if (gen !== this._initGeneration) return;
|
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;
|
const previousActiveId = this.activeSessionId;
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
if (this.sessionOrder.length > 0) {
|
if (this.sessionOrder.length > 0) {
|
||||||
@@ -2182,19 +2387,21 @@ class CodemanApp {
|
|||||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||||
|
|
||||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${this.detachedSessions.has(id) ? ' detached' : ''}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||||
<span class="tab-info">
|
<span class="tab-info">
|
||||||
<span class="tab-name-row">
|
<span class="tab-name-row">
|
||||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
|
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
|
||||||
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
|
||||||
|
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||||
</span>
|
</span>
|
||||||
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
|
||||||
</span>
|
</span>
|
||||||
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
||||||
${subagentBadge}
|
${subagentBadge}
|
||||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||||
|
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession('${escapeHtml(id)}')" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span>
|
||||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||||
</div>`);
|
</div>`);
|
||||||
_tabIdx++;
|
_tabIdx++;
|
||||||
@@ -2525,6 +2732,20 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async selectSession(sessionId) {
|
async selectSession(sessionId) {
|
||||||
|
// If this session is popped out into its own window, raise that window
|
||||||
|
// instead of showing it inline (focus-on-click for detached tabs).
|
||||||
|
if (!this.isSoloWindow && this.detachedSessions.has(sessionId)) {
|
||||||
|
const win = this.detachedWindows.get(sessionId);
|
||||||
|
if (win && !win.closed) { try { win.focus(); } catch {} return; }
|
||||||
|
if (win && win.closed) {
|
||||||
|
this._redock(sessionId); // ref we own is dead → re-dock, fall through to inline
|
||||||
|
} else {
|
||||||
|
// No local ref (dashboard was reloaded). Assume the popup is alive and
|
||||||
|
// raise it via the channel; its unload announcement will heal the badge.
|
||||||
|
this._postWindowMessage({ type: 'focus-request', id: sessionId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (this.activeSessionId === sessionId) return;
|
if (this.activeSessionId === sessionId) return;
|
||||||
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
|
||||||
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
// programmatic focus() within the user-gesture call stack (e.g. tab click).
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
<!-- Resolve all relative assets against the site root so the same shell can be
|
||||||
|
served at /session/:id (detached single-session window) without 404ing
|
||||||
|
on relative <script>/<link> URLs. Must precede the first resource tag. -->
|
||||||
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<meta name="description" content="Claude Code session manager with web interface">
|
<meta name="description" content="Claude Code session manager with web interface">
|
||||||
<meta name="theme-color" content="#0a0a0a">
|
<meta name="theme-color" content="#0a0a0a">
|
||||||
@@ -67,7 +71,11 @@
|
|||||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
|
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Detached single-session window title (shown only in solo mode) -->
|
||||||
|
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||||
<span class="tunnel-dot"></span>
|
<span class="tunnel-dot"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -905,6 +905,96 @@ body {
|
|||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Session detach / undock (beta/session-detach) ===================== */
|
||||||
|
|
||||||
|
/* Pop-out (detach) icon on each tab — mirrors .tab-gear reveal-on-hover. */
|
||||||
|
.session-tab .tab-detach {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: opacity 0.15s, width 0.15s, padding 0.15s;
|
||||||
|
}
|
||||||
|
.session-tab:hover .tab-detach {
|
||||||
|
opacity: 1;
|
||||||
|
width: auto;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
}
|
||||||
|
.session-tab .tab-detach:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A tab whose session is popped out into its own window. */
|
||||||
|
.session-tab.detached {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
.session-tab.detached .tab-detach {
|
||||||
|
/* Keep the pop-out icon visible while detached as a re-focus affordance. */
|
||||||
|
opacity: 1;
|
||||||
|
width: auto;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
.session-tab .tab-detached-badge {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(96, 165, 250, 0.18);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
.session-tab.detached .tab-detached-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Solo (detached single-session) window chrome ===================== */
|
||||||
|
body.solo-mode .session-tabs,
|
||||||
|
body.solo-mode .header-system-stats,
|
||||||
|
body.solo-mode .header-tokens,
|
||||||
|
body.solo-mode .btn-notifications,
|
||||||
|
body.solo-mode .btn-lifecycle-log {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.solo-session-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
/* "Session unavailable" overlay for a solo window whose session has ended. */
|
||||||
|
.solo-gone-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
background: rgba(9, 9, 11, 0.92);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.solo-gone-overlay h2 { margin: 0; font-size: 18px; }
|
||||||
|
.solo-gone-overlay p { margin: 0; color: var(--text-muted); font-size: 13px; }
|
||||||
|
|
||||||
/* Mode indicator on session tab */
|
/* Mode indicator on session tab */
|
||||||
.session-tab .tab-mode {
|
.session-tab .tab-mode {
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.j
|
|||||||
import { SseEvent } from './sse-events.js';
|
import { SseEvent } from './sse-events.js';
|
||||||
import type { SessionPort } from './ports/session-port.js';
|
import type { SessionPort } from './ports/session-port.js';
|
||||||
import type { EventPort } from './ports/event-port.js';
|
import type { EventPort } from './ports/event-port.js';
|
||||||
|
import { dataPath } from '../config/instance.js';
|
||||||
|
|
||||||
// Shared path constants used across route modules
|
// Shared path constants used across route modules. CASES_DIR (project folders)
|
||||||
|
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
|
||||||
export const CASES_DIR = join(homedir(), 'codeman-cases');
|
export const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||||
export const SETTINGS_PATH = join(homedir(), '.codeman', 'settings.json');
|
export const SETTINGS_PATH = dataPath('settings.json');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a path component doesn't escape the base directory.
|
* Validates that a path component doesn't escape the base directory.
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import { writeHooksConfig } from '../../hooks-config.js';
|
|||||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||||
|
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||||
|
|
||||||
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||||
@@ -151,7 +152,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
// Save the linked case
|
// Save the linked case
|
||||||
linkedCases[name] = expandedPath;
|
linkedCases[name] = expandedPath;
|
||||||
try {
|
try {
|
||||||
const codemanDir = join(homedir(), '.codeman');
|
const codemanDir = getDataDir();
|
||||||
if (!existsSync(codemanDir)) {
|
if (!existsSync(codemanDir)) {
|
||||||
mkdirSync(codemanDir, { recursive: true });
|
mkdirSync(codemanDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -206,7 +207,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
|
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dir = join(homedir(), '.codeman');
|
const dir = getDataDir();
|
||||||
if (!existsSync(dir)) {
|
if (!existsSync(dir)) {
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,9 +54,10 @@ import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
|
|||||||
import { RunSummaryTracker } from '../../run-summary.js';
|
import { RunSummaryTracker } from '../../run-summary.js';
|
||||||
|
|
||||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||||
|
import { dataPath } from '../../config/instance.js';
|
||||||
|
|
||||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||||
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||||
|
|
||||||
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { FastifyInstance } from 'fastify';
|
|||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
import { totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { dataPath } from '../../config/instance.js';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||||
import {
|
import {
|
||||||
ConfigUpdateSchema,
|
ConfigUpdateSchema,
|
||||||
@@ -41,7 +42,7 @@ import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
|
|||||||
// Maximum screenshot upload size (10MB)
|
// Maximum screenshot upload size (10MB)
|
||||||
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
||||||
// Screenshots directory
|
// Screenshots directory
|
||||||
const SCREENSHOTS_DIR = join(homedir(), '.codeman', 'screenshots');
|
const SCREENSHOTS_DIR = dataPath('screenshots');
|
||||||
|
|
||||||
/** Cached CPU count — doesn't change at runtime */
|
/** Cached CPU count — doesn't change at runtime */
|
||||||
const CPU_COUNT = cpus().length;
|
const CPU_COUNT = cpus().length;
|
||||||
@@ -96,8 +97,8 @@ export function registerSystemRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||||
): void {
|
): void {
|
||||||
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
|
const windowStatesPath = dataPath('subagent-window-states.json');
|
||||||
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
|
const parentMapPath = dataPath('subagent-parents.json');
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// System Status & Health
|
// System Status & Health
|
||||||
|
|||||||
+33
-10
@@ -39,7 +39,8 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { homedir, hostname as getHostname } from 'node:os';
|
import { hostname as getHostname } from 'node:os';
|
||||||
|
import { dataPath } from '../config/instance.js';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { Session, type BackgroundTask } from '../session.js';
|
import { Session, type BackgroundTask } from '../session.js';
|
||||||
import type { ClaudeMode, SessionState } from '../types.js';
|
import type { ClaudeMode, SessionState } from '../types.js';
|
||||||
@@ -146,7 +147,7 @@ import {
|
|||||||
* Certs are stored in ~/.codeman/certs/ and reused across restarts.
|
* Certs are stored in ~/.codeman/certs/ and reused across restarts.
|
||||||
*/
|
*/
|
||||||
function getOrCreateSelfSignedCert(): { key: string; cert: string } {
|
function getOrCreateSelfSignedCert(): { key: string; cert: string } {
|
||||||
const certsDir = join(homedir(), '.codeman', 'certs');
|
const certsDir = dataPath('certs');
|
||||||
const keyPath = join(certsDir, 'server.key');
|
const keyPath = join(certsDir, 'server.key');
|
||||||
const certPath = join(certsDir, 'server.crt');
|
const certPath = join(certsDir, 'server.crt');
|
||||||
|
|
||||||
@@ -562,6 +563,17 @@ export class WebServer extends EventEmitter {
|
|||||||
this.app.get('/index.html', async (_req, reply) => {
|
this.app.get('/index.html', async (_req, reply) => {
|
||||||
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
|
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
|
||||||
});
|
});
|
||||||
|
// Detached single-session window (undock). Serves the same SPA shell but
|
||||||
|
// flags the client into "solo mode" for one session. Auth applies normally
|
||||||
|
// (the popup carries the dashboard's cookie on navigation). We serve 200
|
||||||
|
// even for an unknown id — the client renders a friendly "session
|
||||||
|
// unavailable" state, which also covers a session that ends while its
|
||||||
|
// detached window is still open. Registered before the static plugin so the
|
||||||
|
// explicit route wins over the '/' static prefix.
|
||||||
|
this.app.get('/session/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml(id));
|
||||||
|
});
|
||||||
// Service worker must never be cached — browsers check for SW updates on navigation
|
// Service worker must never be cached — browsers check for SW updates on navigation
|
||||||
this.app.get('/sw.js', async (_req, reply) => {
|
this.app.get('/sw.js', async (_req, reply) => {
|
||||||
return reply
|
return reply
|
||||||
@@ -980,11 +992,22 @@ export class WebServer extends EventEmitter {
|
|||||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderIndexHtml(): string {
|
private renderIndexHtml(soloSessionId?: string): string {
|
||||||
return this.indexHtmlTemplate.replace(
|
let html = this.indexHtmlTemplate.replace(
|
||||||
'<title>Codeman</title>',
|
'<title>Codeman</title>',
|
||||||
`<title>${escapeHtmlText(this.windowTitle)}</title>`
|
`<title>${escapeHtmlText(this.windowTitle)}</title>`
|
||||||
);
|
);
|
||||||
|
// Detached single-session ("solo") window: inject the target session id so
|
||||||
|
// the client can enter solo mode even if a (network-first) service worker
|
||||||
|
// later serves a cached shell. The client primarily detects solo mode from
|
||||||
|
// the /session/:id URL path; this global is a belt-and-suspenders fallback.
|
||||||
|
// The id is gated to JSON + <-escaped so it can't break out of the inline
|
||||||
|
// <script> (ids are UUIDs in practice, but defense-in-depth is cheap).
|
||||||
|
if (soloSessionId) {
|
||||||
|
const safeId = JSON.stringify(soloSessionId).replace(/</g, '\\u003c');
|
||||||
|
html = html.replace('</head>', `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setupSessionListeners(session: Session): Promise<void> {
|
private async setupSessionListeners(session: Session): Promise<void> {
|
||||||
@@ -1088,7 +1111,7 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
// Helper to get custom CLAUDE.md template path from settings
|
// Helper to get custom CLAUDE.md template path from settings
|
||||||
private async getDefaultClaudeMdPath(): Promise<string | undefined> {
|
private async getDefaultClaudeMdPath(): Promise<string | undefined> {
|
||||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
const settingsPath = dataPath('settings.json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||||
@@ -1112,7 +1135,7 @@ export class WebServer extends EventEmitter {
|
|||||||
if (this._settingsCache && now - this._settingsCache.ts < 2000) {
|
if (this._settingsCache && now - this._settingsCache.ts < 2000) {
|
||||||
return this._settingsCache.data;
|
return this._settingsCache.data;
|
||||||
}
|
}
|
||||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
const settingsPath = dataPath('settings.json');
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||||
const data = JSON.parse(content) as Record<string, unknown>;
|
const data = JSON.parse(content) as Record<string, unknown>;
|
||||||
@@ -1619,7 +1642,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
|
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
|
||||||
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
|
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
|
||||||
if (await this.isTunnelEnabled()) {
|
if (await this.isTunnelEnabled()) {
|
||||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
const settingsPath = dataPath('settings.json');
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||||
const settings = JSON.parse(content);
|
const settings = JSON.parse(content);
|
||||||
@@ -1640,7 +1663,7 @@ export class WebServer extends EventEmitter {
|
|||||||
* Check if subagent tracking is enabled in settings (default: true)
|
* Check if subagent tracking is enabled in settings (default: true)
|
||||||
*/
|
*/
|
||||||
private async isSubagentTrackingEnabled(): Promise<boolean> {
|
private async isSubagentTrackingEnabled(): Promise<boolean> {
|
||||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
const settingsPath = dataPath('settings.json');
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||||
const settings = JSON.parse(content);
|
const settings = JSON.parse(content);
|
||||||
@@ -1658,7 +1681,7 @@ export class WebServer extends EventEmitter {
|
|||||||
* Check if image watcher is enabled in settings (default: false)
|
* Check if image watcher is enabled in settings (default: false)
|
||||||
*/
|
*/
|
||||||
private async isImageWatcherEnabled(): Promise<boolean> {
|
private async isImageWatcherEnabled(): Promise<boolean> {
|
||||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
const settingsPath = dataPath('settings.json');
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||||
const settings = JSON.parse(content);
|
const settings = JSON.parse(content);
|
||||||
@@ -1676,7 +1699,7 @@ export class WebServer extends EventEmitter {
|
|||||||
* Check if Cloudflare tunnel is enabled in settings (default: false)
|
* Check if Cloudflare tunnel is enabled in settings (default: false)
|
||||||
*/
|
*/
|
||||||
private async isTunnelEnabled(): Promise<boolean> {
|
private async isTunnelEnabled(): Promise<boolean> {
|
||||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
const settingsPath = dataPath('settings.json');
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||||
const settings = JSON.parse(content);
|
const settings = JSON.parse(content);
|
||||||
|
|||||||
Reference in New Issue
Block a user