Merge pull request #103 from Ark0N/beta/session-detach

feat(web): session detach/undock + beta instance isolation (port 5000)
This commit is contained in:
Ark0N
2026-06-08 16:36:46 +02:00
committed by GitHub
26 changed files with 5751 additions and 47 deletions
+4 -3
View File
@@ -17,8 +17,9 @@ import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
import { SseEvent } from '../sse-events.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_-]+$/;
/** 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
linkedCases[name] = expandedPath;
try {
const codemanDir = join(homedir(), '.codeman');
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) {
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');
try {
const dir = join(homedir(), '.codeman');
const dir = getDataDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
+2 -1
View File
@@ -54,9 +54,10 @@ import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
import { RunSummaryTracker } from '../../run-summary.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)
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)
// eslint-disable-next-line no-control-regex
+53 -5
View File
@@ -6,11 +6,13 @@
import { FastifyInstance } from 'fastify';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
import { execSync } from 'node:child_process';
import { totalmem, freemem, loadavg, cpus } from 'node:os';
import { execSync, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import {
ConfigUpdateSchema,
@@ -41,7 +43,7 @@ import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
// Maximum screenshot upload size (10MB)
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
// Screenshots directory
const SCREENSHOTS_DIR = join(homedir(), '.codeman', 'screenshots');
const SCREENSHOTS_DIR = dataPath('screenshots');
/** Cached CPU count — doesn't change at runtime */
const CPU_COUNT = cpus().length;
@@ -92,12 +94,24 @@ function getSystemStats(): {
}
}
/**
* Build the URL the spanning browser window should open, pinned to localhost.
* Takes only a digits-only port from the (untrusted) Host header so nothing
* attacker-controllable reaches the launched browser; falls back to the default
* port when the header is absent/odd. Exported for unit testing.
*/
export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3000'): string {
const hostPort = String(hostHeader ?? '').split(':')[1] ?? '';
const port = /^\d+$/.test(hostPort) ? hostPort : fallbackPort;
return `http://localhost:${port}`;
}
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
): void {
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
const windowStatesPath = dataPath('subagent-window-states.json');
const parentMapPath = dataPath('subagent-parents.json');
// ═══════════════════════════════════════════════════════════════
// System Status & Health
@@ -239,6 +253,40 @@ export function registerSystemRoutes(
return { success: true };
});
// ═══════════════════════════════════════════════════════════════
// Multi-monitor: span Codeman across all displays
// ═══════════════════════════════════════════════════════════════
// Spawn scripts/span-codeman.sh, which opens a fresh, maximized browser --app
// window sized to the union of all displays — so in-page floating session
// panels can be dragged across the physical monitor seam. macOS only; needs
// the one-time "Displays have separate Spaces" OFF prerequisite (see script).
app.post('/api/system/span-displays', async (req, reply) => {
// macOS only: the launcher uses osascript + Finder desktop bounds and Chrome
// --app geometry flags. Fail clearly elsewhere instead of spawning a bash
// that errors out invisibly (the toast would otherwise lie "Opening…").
if (process.platform !== 'darwin') {
return reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Multi-monitor spanning is only supported on macOS.'));
}
// Resolve the bundled launcher relative to this module (works from src/ and dist/).
const scriptPath = join(dirname(fileURLToPath(import.meta.url)), '../../../scripts/span-codeman.sh');
if (!existsSync(scriptPath)) {
return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'span-codeman.sh not found'));
}
// Point the spanning window at THIS server (localhost + sanitized port).
const url = resolveSpanUrl(req.headers.host);
try {
const child = spawn('bash', [scriptPath, url], { detached: true, stdio: 'ignore' });
child.on('error', (err) => app.log.error({ err }, 'span-displays launch failed'));
child.unref();
return { success: true, url };
} catch (err) {
return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err)));
}
});
// ═══════════════════════════════════════════════════════════════
// CLI Integrations (OpenCode)
// ═══════════════════════════════════════════════════════════════