Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|------|---------|
Expand All @@ -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-<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.

Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ program
program
.command('web')
.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('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
.action(async (options) => {
Expand Down
60 changes: 60 additions & 0 deletions src/config/instance.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 2 additions & 2 deletions src/push-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/session-lifecycle-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,7 +22,7 @@ export class SessionLifecycleLog {
private writeQueue: Promise<void> = 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 });
Expand Down
9 changes: 6 additions & 3 deletions src/state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
TokenUsageEntry,
} from './types.js';
import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js';
import { dataPath, CODEMAN_INSTANCE } from './config/instance.js';

/** Debounce delay for batching state writes (ms) */
const SAVE_DEBOUNCE_MS = 500;
Expand Down Expand Up @@ -89,8 +90,10 @@ export class StateStore {
private _saveInFlight: Promise<void> | null = null;

constructor(filePath?: string) {
// Migrate legacy data directory (~/.claudeman → ~/.codeman)
if (!filePath) {
// Migrate legacy data directory (~/.claudeman → ~/.codeman). Default (prod)
// instance only — a named instance (e.g. beta) must never touch the shared
// ~/.codeman / ~/codeman-cases layout, preserving instance isolation.
if (!filePath && !CODEMAN_INSTANCE) {
const legacyDir = join(homedir(), '.claudeman');
const newDir = join(homedir(), '.codeman');
if (existsSync(legacyDir) && !existsSync(newDir)) {
Expand All @@ -105,7 +108,7 @@ export class StateStore {
}
}

this.filePath = filePath || join(homedir(), '.codeman', 'state.json');
this.filePath = filePath || dataPath('state.json');
this.ralphStatePath = this.filePath.replace('.json', '-inner.json');
this.state = this.load();
this.state.config.stateFilePath = this.filePath;
Expand Down
11 changes: 6 additions & 5 deletions src/tmux-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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-]+$/;
Expand All @@ -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_.-]+$/;
Expand Down
Loading