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:
+33
-10
@@ -39,7 +39,8 @@ import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
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 { Session, type BackgroundTask } from '../session.js';
|
||||
import type { ClaudeMode, SessionState } from '../types.js';
|
||||
@@ -146,7 +147,7 @@ import {
|
||||
* Certs are stored in ~/.codeman/certs/ and reused across restarts.
|
||||
*/
|
||||
function getOrCreateSelfSignedCert(): { key: string; cert: string } {
|
||||
const certsDir = join(homedir(), '.codeman', 'certs');
|
||||
const certsDir = dataPath('certs');
|
||||
const keyPath = join(certsDir, 'server.key');
|
||||
const certPath = join(certsDir, 'server.crt');
|
||||
|
||||
@@ -562,6 +563,17 @@ export class WebServer extends EventEmitter {
|
||||
this.app.get('/index.html', async (_req, reply) => {
|
||||
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
|
||||
this.app.get('/sw.js', async (_req, reply) => {
|
||||
return reply
|
||||
@@ -980,11 +992,22 @@ export class WebServer extends EventEmitter {
|
||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||
}
|
||||
|
||||
private renderIndexHtml(): string {
|
||||
return this.indexHtmlTemplate.replace(
|
||||
private renderIndexHtml(soloSessionId?: string): string {
|
||||
let html = this.indexHtmlTemplate.replace(
|
||||
'<title>Codeman</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> {
|
||||
@@ -1088,7 +1111,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Helper to get custom CLAUDE.md template path from settings
|
||||
private async getDefaultClaudeMdPath(): Promise<string | undefined> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
|
||||
try {
|
||||
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) {
|
||||
return this._settingsCache.data;
|
||||
}
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
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.
|
||||
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
|
||||
if (await this.isTunnelEnabled()) {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
@@ -1640,7 +1663,7 @@ export class WebServer extends EventEmitter {
|
||||
* Check if subagent tracking is enabled in settings (default: true)
|
||||
*/
|
||||
private async isSubagentTrackingEnabled(): Promise<boolean> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
@@ -1658,7 +1681,7 @@ export class WebServer extends EventEmitter {
|
||||
* Check if image watcher is enabled in settings (default: false)
|
||||
*/
|
||||
private async isImageWatcherEnabled(): Promise<boolean> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
@@ -1676,7 +1699,7 @@ export class WebServer extends EventEmitter {
|
||||
* Check if Cloudflare tunnel is enabled in settings (default: false)
|
||||
*/
|
||||
private async isTunnelEnabled(): Promise<boolean> {
|
||||
const settingsPath = join(homedir(), '.codeman', 'settings.json');
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
Reference in New Issue
Block a user