From a4e778c7c4325a79f20fa03797fe7eadcb091dcb Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 12 Feb 2026 13:44:07 +0100 Subject: [PATCH] chore: bump version to 0.1486 feat: interactive teammate tmux pane windows with xterm.js terminals feat: auto-cleanup teams/subagents/pane windows on session delete fix: only show agent/teammate windows when matching Claudeman tab exists fix: UTF-8 encoding in teammate pane terminal output (Uint8Array) fix: standalone pane window cleanup via subagentParentMap lookup fix: xterm.js dimensions crash with deferred init + null-safe dispose Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- README.md | 22 +- agent-teams/README.md | 2 +- install.sh | 59 +++- package.json | 2 +- scripts/postinstall.js | 31 +- src/ai-checker-base.ts | 10 +- src/session.ts | 8 +- src/team-watcher.ts | 51 ++- src/templates/case-template.md | 4 +- src/tmux-manager.ts | 216 +++++++++++- src/types.ts | 25 +- src/web/public/app.js | 381 ++++++++++++++++++++- src/web/public/index.html | 2 +- src/web/public/styles.css | 14 + src/web/schemas.ts | 13 +- src/web/server.ts | 269 ++++++++++++++- test/bash-tool-parser.test.ts | 570 +++++++++++++++++++++++++++++++ test/file-stream-manager.test.ts | 531 ++++++++++++++++++++++++++++ test/image-watcher.test.ts | 415 ++++++++++++++++++++++ test/nice-wrapper.test.ts | 62 ++++ vitest.config.ts | 2 +- 22 files changed, 2589 insertions(+), 102 deletions(-) create mode 100644 test/bash-tool-parser.test.ts create mode 100644 test/file-stream-manager.test.ts create mode 100644 test/image-watcher.test.ts create mode 100644 test/nice-wrapper.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5eb56f8a..02fe4aad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1485 (must match `package.json` for npm publish) +**Version**: 0.1486 (must match `package.json` for npm publish) ## Project Overview diff --git a/README.md b/README.md index 9b331ce0..2a0b4e8b 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ Real-time desktop notifications when sessions need attention — never miss a pe --- -### 💾 Persistent Screen Sessions +### 💾 Persistent Sessions -Every Claude session runs inside **GNU Screen** — sessions survive server restarts, network drops, and machine sleep. +Every Claude session runs inside **tmux** (or GNU Screen as fallback) — sessions survive server restarts, network drops, and machine sleep. ```bash # Your sessions are always recoverable @@ -63,10 +63,10 @@ CLAUDEMAN_SESSION_ID=abc-123-def CLAUDEMAN_SCREEN_NAME=claudeman-myproject ``` -- Sessions auto-recover on startup (dual redundancy: `state.json` + `screens.json`) +- Sessions auto-recover on startup (dual redundancy: `state.json` + `mux-sessions.json`) - All settings (respawn, auto-compact, tokens) survive server restarts -- Ghost session discovery finds orphaned screens -- Claude knows it's managed (won't kill its own screen) +- Ghost session discovery finds orphaned mux sessions +- Claude knows it's managed (won't kill its own session) --- @@ -203,7 +203,7 @@ Run **20 parallel sessions** with full visibility: Multi-Session Dashboard

-**Monitor Panel** — Real-time screen session monitoring with memory, CPU, and process info: +**Monitor Panel** — Real-time session monitoring with memory, CPU, and process info:

Monitor Panel @@ -322,7 +322,7 @@ npm install -g claudeman - Node.js 18+ - [Claude CLI](https://docs.anthropic.com/en/docs/claude-code/getting-started) installed -- GNU Screen (`apt install screen` / `brew install screen`) +- tmux (`apt install tmux` / `brew install tmux`) — preferred; GNU Screen as fallback (`CLAUDEMAN_MUX=screen`) ## Getting Started @@ -365,7 +365,7 @@ claudeman web | Tiny text, no context | Full xterm.js terminal, responsive layout | | No session management | Swipe between sessions, tab badges show status | | No notifications | Push alerts when Claude needs approval or goes idle | -| Manual reconnect after drops | GNU Screen persistence — sessions survive anything | +| Manual reconnect after drops | tmux persistence — sessions survive anything | | No agent visibility | See background agents working in real-time | | No token/cost tracking | Live token counts and cost per session | | Copy-paste slash commands | One-tap `/init`, `/clear`, `/compact` buttons | @@ -417,9 +417,9 @@ The mobile UI strips away desktop complexity to keep things fast and focused: --- -### Claudeman Screens (`sc`) — Mobile SSH Alternative +### Claudeman Sessions (`sc`) — Mobile SSH Alternative -If you prefer SSH (via Termius, Blink, etc.), the `sc` command is a thumb-friendly screen session chooser: +If you prefer SSH (via Termius, Blink, etc.), the `sc` command is a thumb-friendly session chooser: ```bash sc # Interactive chooser @@ -517,7 +517,7 @@ flowchart TB end subgraph Persistence["Persistence Layer"] - SCR["GNU Screen Manager"] + SCR["Mux Manager
(tmux/screen)"] SS["State Store
state.json"] end diff --git a/agent-teams/README.md b/agent-teams/README.md index 0fca6e34..3d775128 100644 --- a/agent-teams/README.md +++ b/agent-teams/README.md @@ -138,7 +138,7 @@ This means: | **tmux** | `--teammate-mode tmux` | Split panes | tmux installed | | **iTerm2** | Auto-detected | Native split panes | iTerm2 + `it2` CLI | -**For Claudeman: use `in-process` only.** Claudeman uses GNU Screen, not tmux. +**For Claudeman: use `in-process` only.** Claudeman manages its own tmux/screen sessions externally. **In-process UI elements:** - Status bar: `@main @teammate1 @teammate2 ...` with `shift+↑ to expand` diff --git a/install.sh b/install.sh index 229fa5cf..4daddc81 100755 --- a/install.sh +++ b/install.sh @@ -239,6 +239,10 @@ check_git() { command -v git &>/dev/null } +check_tmux() { + command -v tmux &>/dev/null +} + check_screen() { command -v screen &>/dev/null } @@ -388,6 +392,41 @@ install_node_suse() { run_as_root zypper install -y nodejs } +install_tmux_macos() { + info "Installing tmux via Homebrew..." + brew install tmux +} + +install_tmux_debian() { + info "Installing tmux via apt..." + ensure_sudo + run_as_root apt-get update -qq + run_as_root apt-get install -y -qq tmux +} + +install_tmux_fedora() { + info "Installing tmux via dnf..." + ensure_sudo + run_as_root dnf install -y tmux +} + +install_tmux_arch() { + info "Installing tmux via pacman..." + ensure_sudo + run_as_root pacman -Sy --noconfirm tmux +} + +install_tmux_alpine() { + info "Installing tmux via apk..." + run_as_root apk add --no-cache tmux +} + +install_tmux_suse() { + info "Installing tmux via zypper..." + ensure_sudo + run_as_root zypper install -y tmux +} + install_screen_macos() { info "Installing GNU Screen via Homebrew..." @@ -810,16 +849,18 @@ main() { die "npm is not available. Please reinstall Node.js." fi - # GNU Screen - info "Checking GNU Screen..." - if ! check_screen; then - if prompt_yes_no "GNU Screen is not installed. Install it now?"; then - install_dependency "screen" "$os" "$distro" - else - die "GNU Screen is required for session persistence." - fi + # Terminal multiplexer (tmux preferred, GNU Screen as fallback) + info "Checking terminal multiplexer..." + if check_tmux; then + success "tmux is installed (preferred)" + elif check_screen; then + success "GNU Screen is installed (fallback — consider installing tmux for better performance)" else - success "GNU Screen is installed" + if prompt_yes_no "tmux is not installed. Install it now?"; then + install_dependency "tmux" "$os" "$distro" + else + die "A terminal multiplexer (tmux or GNU Screen) is required for session persistence." + fi fi # Claude CLI (warning only) diff --git a/package.json b/package.json index be8bf04b..9de2d556 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1485", + "version": "0.1486", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 9aa8ce58..49b0f541 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -60,34 +60,33 @@ function commandExists(cmd) { } /** - * Get install instructions for screen based on platform + * Get install instructions for tmux based on platform */ -function getScreenInstallInstructions() { +function getTmuxInstallInstructions() { const os = platform(); if (os === 'darwin') { return [ - ' macOS: brew install screen', + ' macOS: brew install tmux', ]; } if (os === 'linux') { return [ - ' Ubuntu/Debian: sudo apt install screen', - ' Fedora/RHEL: sudo dnf install screen', - ' Arch Linux: sudo pacman -S screen', - ' Alpine: sudo apk add screen', + ' Ubuntu/Debian: sudo apt install tmux', + ' Fedora/RHEL: sudo dnf install tmux', + ' Arch Linux: sudo pacman -S tmux', + ' Alpine: sudo apk add tmux', ]; } if (os === 'win32') { return [ ' Windows: Use WSL (Windows Subsystem for Linux)', - ' GNU Screen is not available on native Windows', ]; } - return [' Please install GNU Screen for your platform']; + return [' Please install tmux for your platform']; } // ============================================================================ @@ -117,17 +116,19 @@ if (majorVersion < MIN_NODE_VERSION) { } // ---------------------------------------------------------------------------- -// 2. Check if GNU Screen is installed +// 2. Check if terminal multiplexer is installed (tmux preferred, screen fallback) // ---------------------------------------------------------------------------- -if (commandExists('screen')) { - console.log(colors.green('✓ GNU Screen found')); +if (commandExists('tmux')) { + console.log(colors.green('✓ tmux found (preferred)')); +} else if (commandExists('screen')) { + console.log(colors.green('✓ GNU Screen found') + colors.dim(' (fallback — consider installing tmux)')); } else { hasWarnings = true; - console.log(colors.yellow('⚠ GNU Screen not found')); - console.log(colors.dim(' Screen is required for session persistence.')); + console.log(colors.yellow('⚠ No terminal multiplexer found')); + console.log(colors.dim(' tmux is required for session persistence.')); console.log(colors.dim(' Install:')); - for (const instruction of getScreenInstallInstructions()) { + for (const instruction of getTmuxInstallInstructions()) { console.log(colors.dim(instruction)); } } diff --git a/src/ai-checker-base.ts b/src/ai-checker-base.ts index f26b6a53..433c322a 100644 --- a/src/ai-checker-base.ts +++ b/src/ai-checker-base.ts @@ -3,7 +3,7 @@ * * Provides shared functionality for AI-powered checkers that spawn fresh Claude CLI * sessions to analyze terminal output. This base class handles: - * - Screen session spawning and cleanup + * - Mux session spawning and cleanup * - Temp file management for output capture * - Polling for completion markers * - Cooldown management @@ -11,7 +11,7 @@ * - Event emission for state changes * * Subclasses implement: - * - `screenNamePrefix`: Prefix for screen session names (e.g., 'claudeman-aicheck-') + * - `screenNamePrefix`: Prefix for mux session names (e.g., 'claudeman-aicheck-') * - `doneMarker`: Completion marker in output file (e.g., '__AICHECK_DONE__') * - `tempFilePrefix`: Prefix for temp files (e.g., 'claudeman-aicheck') * - `logPrefix`: Prefix for log messages (e.g., '[AiIdleChecker]') @@ -115,7 +115,7 @@ const POLL_INTERVAL_MS = 500; /** * Abstract base class for AI-powered checkers. - * Handles spawning Claude CLI in a screen session to analyze terminal output. + * Handles spawning Claude CLI in a mux session to analyze terminal output. * * @template V - The verdict type (e.g., 'IDLE' | 'WORKING' | 'ERROR') * @template C - The configuration type @@ -154,7 +154,7 @@ export abstract class AiCheckerBase< // ========== Abstract Properties ========== - /** Prefix for screen session names (e.g., 'claudeman-aicheck-') */ + /** Prefix for mux session names (e.g., 'claudeman-aicheck-') */ protected abstract readonly screenNamePrefix: string; /** Marker written to temp file when check is complete */ @@ -318,7 +318,7 @@ export abstract class AiCheckerBase< /** * Cancel an in-progress check. - * Kills the screen session and cleans up. + * Kills the mux session and cleans up. */ cancel(): void { if (this._status !== 'checking') return; diff --git a/src/session.ts b/src/session.ts index 97571d4b..3647ed70 100644 --- a/src/session.ts +++ b/src/session.ts @@ -8,8 +8,8 @@ * 2. **Interactive mode** (`startInteractive`): Start an interactive Claude session * 3. **Shell mode**: Run a plain bash shell for debugging/testing * - * The session can optionally run inside a GNU Screen session for persistence - * across disconnects. It tracks tokens, costs, background tasks, and supports + * The session can optionally run inside a tmux session (or GNU Screen as fallback) + * for persistence across disconnects. It tracks tokens, costs, background tasks, and supports * auto-clear/auto-compact functionality when token limits are approached. * * @module session @@ -853,7 +853,7 @@ export class Session extends EventEmitter { * * This spawns Claude CLI with `--dangerously-skip-permissions` flag in * interactive mode. If screen wrapping is enabled, the session runs inside - * a GNU Screen session for persistence across disconnects. + * a tmux session (or GNU Screen as fallback) for persistence across disconnects. * * @throws {Error} If a process is already running in this session * @@ -1997,7 +1997,7 @@ export class Session extends EventEmitter { /** * Stops the session and cleans up resources. * - * This kills the PTY process and optionally the associated GNU Screen + * This kills the PTY process and optionally the associated tmux/screen * session. All buffers are cleared and the session is marked as stopped. * * @param killScreen - Whether to also kill the screen session (default: true) diff --git a/src/team-watcher.ts b/src/team-watcher.ts index c3a7ecea..831aa9ab 100644 --- a/src/team-watcher.ts +++ b/src/team-watcher.ts @@ -9,11 +9,11 @@ */ import { EventEmitter } from 'node:events'; -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import type { TeamConfig, TeamTask, InboxMessage } from './types.js'; +import type { TeamConfig, TeamMember, TeamTask, InboxMessage } from './types.js'; import { LRUMap } from './utils/lru-map.js'; // ========== Constants ========== @@ -33,7 +33,7 @@ export class TeamWatcher extends EventEmitter { private inboxCache: LRUMap = new LRUMap({ maxSize: MAX_CACHED_TASKS }); // Track config mtimes to avoid re-reading unchanged files private configMtimes: Map = new Map(); - private taskMtimes: Map = new Map(); + private taskMtimes: Map = new Map(); private inboxMtimes: Map = new Map(); constructor(teamsDir?: string, tasksDir?: string) { @@ -122,6 +122,19 @@ export class TeamWatcher extends EventEmitter { return activeTasks > 0; } + /** Get teammates that have real tmux panes (not in-process) */ + getTmuxPaneTeammates(teamName: string): Array { + const team = this.teams.get(teamName); + if (!team) return []; + + return team.members + .filter((m): m is TeamMember & { tmuxPaneId: string } => + m.agentType !== 'team-lead' && + !!m.tmuxPaneId && + m.tmuxPaneId !== 'in-process' + ); + } + /** Get count of active teammates for a session */ getActiveTeammateCount(sessionId: string): number { const team = this.getTeamForSession(sessionId); @@ -142,8 +155,6 @@ export class TeamWatcher extends EventEmitter { } private pollTeams(): void { - if (!existsSync(this.teamsDir)) return; - let entries: string[]; try { entries = readdirSync(this.teamsDir); @@ -155,24 +166,25 @@ export class TeamWatcher extends EventEmitter { for (const entry of entries) { const configPath = join(this.teamsDir, entry, 'config.json'); - if (!existsSync(configPath)) continue; currentTeamNames.add(entry); - // Check mtime to skip unchanged configs + // Check mtime to skip unchanged configs (stat instead of existsSync to avoid TOCTOU) + let mtime: number; try { - const mtime = statSync(configPath).mtimeMs; - if (this.configMtimes.get(entry) === mtime) continue; - this.configMtimes.set(entry, mtime); + mtime = statSync(configPath).mtimeMs; } catch { + // File doesn't exist or was removed between readdir and stat continue; } + if (this.configMtimes.get(entry) === mtime) continue; + this.configMtimes.set(entry, mtime); // Skip if locked if (this.isLocked(join(this.teamsDir, entry, 'config.json'))) continue; const config = this.readJson(configPath); - if (!config || !config.name) continue; + if (!config || !config.name || !config.leadSessionId || !Array.isArray(config.members)) continue; const existing = this.teams.get(entry); this.teams.set(entry, config); @@ -198,8 +210,6 @@ export class TeamWatcher extends EventEmitter { } private pollTasks(): void { - if (!existsSync(this.tasksDir)) return; - let teamDirs: string[]; try { teamDirs = readdirSync(this.tasksDir); @@ -216,16 +226,22 @@ export class TeamWatcher extends EventEmitter { continue; } - // Check combined mtime for all task files + // Check combined mtime for all task files (use count:max:sum to avoid collisions) const mtimeKey = teamName; - let combinedMtime = 0; + let mtimeSum = 0; + let mtimeMax = 0; + let mtimeCount = 0; for (const f of taskFiles) { try { - combinedMtime += statSync(join(teamTaskDir, f)).mtimeMs; + const mt = statSync(join(teamTaskDir, f)).mtimeMs; + mtimeSum += mt; + if (mt > mtimeMax) mtimeMax = mt; + mtimeCount++; } catch { // File may have been deleted between readdir and stat } } + const combinedMtime = `${mtimeCount}:${mtimeMax}:${mtimeSum}`; if (this.taskMtimes.get(mtimeKey) === combinedMtime) continue; this.taskMtimes.set(mtimeKey, combinedMtime); @@ -249,7 +265,6 @@ export class TeamWatcher extends EventEmitter { // Inbox files live under ~/.claude/teams/{name}/inboxes/ for (const [teamName] of this.teams.entries()) { const inboxDir = join(this.teamsDir, teamName, 'inboxes'); - if (!existsSync(inboxDir)) continue; let inboxFiles: string[]; try { @@ -296,7 +311,7 @@ export class TeamWatcher extends EventEmitter { private isLocked(path: string): boolean { const lockDir = `${path}.lock`; try { - return existsSync(lockDir) && statSync(lockDir).isDirectory(); + return statSync(lockDir).isDirectory(); } catch { return false; } diff --git a/src/templates/case-template.md b/src/templates/case-template.md index 2996eb77..1d1d8083 100644 --- a/src/templates/case-template.md +++ b/src/templates/case-template.md @@ -20,10 +20,10 @@ Then update the Project Overview section below. ## Claudeman Environment -This session is managed by **Claudeman** and runs within a GNU Screen session. +This session is managed by **Claudeman** and runs within a tmux session (or GNU Screen as fallback). **Important**: Check for `CLAUDEMAN_SCREEN=1` environment variable to confirm. -- Do NOT attempt to kill your own screen session +- Do NOT attempt to kill your own tmux/screen session - The session persists across disconnects - your work is safe - Token usage, costs, and background tasks are tracked externally diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 1eaf97f5..91020da6 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -26,7 +26,7 @@ import { spawn, execSync } from 'node:child_process'; import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; -import { ProcessStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js'; +import { ProcessStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG, type PaneInfo } from './types.js'; import { wrapWithNice } from './utils/nice-wrapper.js'; import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js'; @@ -105,6 +105,9 @@ const SAFE_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/; /** Regex to validate working directory paths (no shell metacharacters) */ const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/; +/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */ +const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/; + /** * Validates that a session name contains only safe characters. * Prevents command injection via malformed session IDs. @@ -298,6 +301,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Non-critical — session still works with status bar } + // Enable mouse mode — allows clicking to select tmux panes when + // Claude Code creates agent team split panes within this session. + // With mouse mode, xterm.js forwards click events as escape sequences + // that tmux interprets for pane selection. + // Trade-off: text selection requires Shift+click (minor for web UI users). + try { + execSync( + `tmux set-option -t "${muxName}" mouse on`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + } catch { + // Non-critical — pane clicking won't work but keyboard input still does + } + // Enable 24-bit true color passthrough — without this, tmux downgrades // RGB colors (like Claude's red logo) to the nearest 256-color palette entry. // Server-wide option, only set once per TmuxManager lifetime to avoid duplicates. @@ -877,6 +894,203 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + // ========== Pane Methods (for Agent Team teammate panes) ========== + + /** + * Enable mouse mode for an existing tmux session. + * Allows clicking to select panes in agent team split-pane layouts. + */ + enableMouseMode(muxName: string): boolean { + if (!isValidMuxName(muxName)) { + console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName); + return false; + } + + try { + execSync( + `tmux set-option -t "${muxName}" mouse on`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + return true; + } catch { + return false; + } + } + + /** + * List all panes in a tmux session. + * Returns structured info for each pane. + */ + listPanes(muxName: string): PaneInfo[] { + if (!isValidMuxName(muxName)) { + console.error('[TmuxManager] Invalid session name in listPanes:', muxName); + return []; + } + + try { + const output = execSync( + `tmux list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ).trim(); + + return output.split('\n').map(line => { + const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':'); + return { + paneId, + paneIndex: parseInt(indexStr, 10), + panePid: parseInt(pidStr, 10), + width: parseInt(widthStr, 10), + height: parseInt(heightStr, 10), + }; + }).filter(p => !Number.isNaN(p.paneIndex)); + } catch { + return []; + } + } + + /** + * Send input to a specific pane within a tmux session. + * Uses the same literal text approach as sendInput() but targets a specific pane. + */ + sendInputToPane(muxName: string, paneTarget: string, input: string): boolean { + if (!isValidMuxName(muxName)) { + console.error('[TmuxManager] Invalid session name in sendInputToPane:', muxName); + return false; + } + if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) { + console.error('[TmuxManager] Invalid pane target:', paneTarget); + return false; + } + + // Build target: sessionName.paneId (e.g., "claudeman-abc12345.%1") + const target = paneTarget.startsWith('%') + ? `${muxName}.${paneTarget}` + : `${muxName}.%${paneTarget}`; + + try { + const hasCarriageReturn = input.includes('\r'); + const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); + + if (textPart && hasCarriageReturn) { + execSync( + `tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + execSync( + `tmux send-keys -t ${shellescape(target)} Enter`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + } else if (textPart) { + execSync( + `tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + } else if (hasCarriageReturn) { + execSync( + `tmux send-keys -t ${shellescape(target)} Enter`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + } + + return true; + } catch (err) { + console.error('[TmuxManager] Failed to send input to pane:', err); + return false; + } + } + + /** + * Capture the current buffer of a specific pane. + * Returns the pane content with ANSI escape codes preserved. + */ + capturePaneBuffer(muxName: string, paneTarget: string): string | null { + if (!isValidMuxName(muxName)) { + console.error('[TmuxManager] Invalid session name in capturePaneBuffer:', muxName); + return null; + } + if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) { + console.error('[TmuxManager] Invalid pane target:', paneTarget); + return null; + } + + const target = paneTarget.startsWith('%') + ? `${muxName}.${paneTarget}` + : `${muxName}.%${paneTarget}`; + + try { + return execSync( + `tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + } catch (err) { + console.error('[TmuxManager] Failed to capture pane buffer:', err); + return null; + } + } + + /** + * Start piping pane output to a file using tmux pipe-pane. + * Only pipes output direction (-O) to avoid echoing input. + */ + startPipePane(muxName: string, paneTarget: string, outputFile: string): boolean { + if (!isValidMuxName(muxName)) { + console.error('[TmuxManager] Invalid session name in startPipePane:', muxName); + return false; + } + if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) { + console.error('[TmuxManager] Invalid pane target:', paneTarget); + return false; + } + if (!isValidPath(outputFile)) { + console.error('[TmuxManager] Invalid output file path:', outputFile); + return false; + } + + const target = paneTarget.startsWith('%') + ? `${muxName}.${paneTarget}` + : `${muxName}.%${paneTarget}`; + + try { + execSync( + `tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + return true; + } catch (err) { + console.error('[TmuxManager] Failed to start pipe-pane:', err); + return false; + } + } + + /** + * Stop piping pane output (calling pipe-pane with no command stops piping). + */ + stopPipePane(muxName: string, paneTarget: string): boolean { + if (!isValidMuxName(muxName)) { + console.error('[TmuxManager] Invalid session name in stopPipePane:', muxName); + return false; + } + if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) { + console.error('[TmuxManager] Invalid pane target:', paneTarget); + return false; + } + + const target = paneTarget.startsWith('%') + ? `${muxName}.${paneTarget}` + : `${muxName}.%${paneTarget}`; + + try { + execSync( + `tmux pipe-pane -t ${shellescape(target)}`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + return true; + } catch (err) { + console.error('[TmuxManager] Failed to stop pipe-pane:', err); + return false; + } + } + getAttachCommand(): string { return 'tmux'; } diff --git a/src/types.ts b/src/types.ts index 732e2180..871f8f4f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -855,9 +855,9 @@ export interface CaseInfo { // ========== Screen Session Types ========== /** - * GNU screen session wrapper + * Terminal multiplexer session wrapper. * - * Claudeman uses GNU screen for session persistence across server restarts. + * Claudeman uses tmux (preferred) or GNU Screen (fallback) for session persistence. */ /** * Persisted respawn configuration for screen sessions. @@ -913,7 +913,7 @@ export interface PersistedRespawnConfig { export interface ScreenSession { /** Claudeman session ID */ sessionId: string; - /** GNU screen session name (claudeman-) */ + /** Mux session name (claudeman-) */ screenName: string; /** Screen process PID */ pid: number; @@ -1514,6 +1514,25 @@ export interface ImageDetectedEvent { size: number; } +// ========== Pane Types ========== + +/** + * Information about a tmux pane within a session. + * Used for agent team teammate pane management. + */ +export interface PaneInfo { + /** Pane ID (e.g., "%0", "%1") — immutable within a tmux session */ + paneId: string; + /** Pane index within the window (0, 1, 2...) */ + paneIndex: number; + /** PID of the process running in the pane */ + panePid: number; + /** Pane width in columns */ + width: number; + /** Pane height in rows */ + height: number; +} + // ========== Agent Teams Types ========== /** diff --git a/src/web/public/app.js b/src/web/public/app.js index e09d8723..81cb6f31 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1149,6 +1149,10 @@ class ClaudemanApp { this.teams = new Map(); // Map this.teamTasks = new Map(); // Map this.teammateMap = new Map(); // Map for quick lookup + + // Teammate tmux pane terminals + this.teammatePanesByName = new Map(); // Map + this.teammateTerminals = new Map(); // Map this.ralphStatePanelCollapsed = true; // Default to collapsed this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel @@ -2103,6 +2107,23 @@ class ClaudemanApp { this.closeSessionLogViewerWindows(data.id); // Close log viewer windows for this session this.closeSessionImagePopups(data.id); // Close image popup windows for this session this.closeSessionSubagentWindows(data.id, true); // Close subagent windows and cleanup activity data + + // Clean up any agent teams associated with this session + for (const [teamName, team] of this.teams) { + if (team.leadSessionId === data.id) { + this.teams.delete(teamName); + this.teamTasks.delete(teamName); + } + } + // Clean up teammate pane tracking for this session + for (const [name, paneInfo] of this.teammatePanesByName) { + if (paneInfo.sessionId === data.id) { + this.teammatePanesByName.delete(name); + } + } + this.rebuildTeammateMap(); + this.renderTeamTasksPanel(); + // Clean up idle timer for this session const idleTimer = this.idleTimers.get(data.id); if (idleTimer) { @@ -2531,7 +2552,7 @@ class ClaudemanApp { const data = JSON.parse(e.data); this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId); this.renderScreenSessions(); - this.showToast('Screen session died: ' + data.sessionId.slice(0, 8), 'warning'); + this.showToast('Mux session died: ' + data.sessionId.slice(0, 8), 'warning'); }); addListener('screen:statsUpdated', (e) => { @@ -2736,9 +2757,15 @@ class ClaudemanApp { // Find which Claudeman session owns this subagent (direct claudeSessionId match only) this.findParentSessionForSubagent(data.agentId); - // Auto-open window for new active agents + // Auto-open window for new active agents — but ONLY if they belong to a Claudeman session tab. + // Agents from external Claude sessions (not managed by Claudeman) should not pop up. if (data.status === 'active') { - this.openSubagentWindow(data.agentId); + const agentForCheck = this.subagents.get(data.agentId); + const hasMatchingTab = agentForCheck?.sessionId && + Array.from(this.sessions.values()).some(s => s.claudeSessionId === agentForCheck.sessionId); + if (hasMatchingTab) { + this.openSubagentWindow(data.agentId); + } } // Ensure connection lines are updated after window is created and DOM settles @@ -2903,6 +2930,57 @@ class ClaudemanApp { this.renderTeamTasksPanel(); }); + // ========== Teammate Pane Events ========== + + addListener('teammate:pane_available', (e) => { + const data = JSON.parse(e.data); + + // Only show teammate pane windows if the session has a tab in Claudeman + if (!this.sessions.has(data.sessionId)) return; + + this.teammatePanesByName.set(data.teammateName, { + paneTarget: data.paneTarget, + sessionId: data.sessionId, + color: data.color, + }); + // Upgrade any existing subagent windows for this teammate to terminal mode + let foundExisting = false; + for (const [agentId, agent] of this.subagents) { + const info = this.getTeammateInfo(agent); + if (info && info.name === data.teammateName && !this.teammateTerminals.has(agentId)) { + const windowData = this.subagentWindows.get(agentId); + if (windowData) { + this.initTeammateTerminal(agentId, data, windowData.element); + foundExisting = true; + } + } + } + // If no existing subagent window found, create a standalone teammate terminal window + if (!foundExisting) { + this.openTeammateTerminalWindow(data); + } + }); + + addListener('teammate:terminal', (e) => { + const data = JSON.parse(e.data); + // Find the terminal instance for this pane + for (const [, termData] of this.teammateTerminals) { + if (termData.paneTarget === data.paneTarget && termData.sessionId === data.sessionId) { + // Decode base64 to Uint8Array to preserve UTF-8 multi-byte sequences + const binary = atob(data.data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + if (termData.terminal) { + try { termData.terminal.write(bytes); } catch {} + } else if (termData.pendingData) { + // Terminal not ready yet — buffer the data + termData.pendingData.push(bytes); + } + break; + } + } + }); + // ========== Image Detection Events (Screenshots & Generated Images) ========== addListener('image:detected', (e) => { @@ -9906,7 +9984,7 @@ class ClaudemanApp { } } - // ========== Monitor Panel (combined Screen Sessions + Background Tasks) ========== + // ========== Monitor Panel (combined Mux Sessions + Background Tasks) ========== async toggleMonitorPanel() { const panel = document.getElementById('monitorPanel'); @@ -11972,6 +12050,13 @@ class ClaudemanApp { const agent = this.subagents.get(agentId); if (!agent) return; + // Only open windows for agents that belong to a Claudeman-managed session tab. + // Agents from external Claude sessions (not tracked by Claudeman) should not pop up. + if (agent.sessionId) { + const hasMatchingTab = Array.from(this.sessions.values()).some(s => s.claudeSessionId === agent.sessionId); + if (!hasMatchingTab) return; + } + // Calculate final position - grid layout to avoid overlaps const windowCount = this.subagentWindows.size; const windowWidth = 420; @@ -12133,8 +12218,13 @@ class ClaudemanApp { win.style.display = 'none'; } - // Render content - this.renderSubagentWindowContent(agentId); + // Render content — check if this teammate has a tmux pane + const paneInfo = teammateInfo ? this.teammatePanesByName.get(teammateInfo.name) : null; + if (paneInfo) { + this.initTeammateTerminal(agentId, paneInfo, win); + } else { + this.renderSubagentWindowContent(agentId); + } // Focus on click win.addEventListener('mousedown', () => { @@ -12217,7 +12307,10 @@ class ClaudemanApp { const toClose = []; for (const [agentId, _windowData] of this.subagentWindows) { const agent = this.subagents.get(agentId); - if (agent?.parentSessionId === sessionId) { + // Check both subagent parentSessionId and subagentParentMap + // (standalone pane windows use subagentParentMap, not subagents map) + const parentFromMap = this.subagentParentMap.get(agentId); + if (agent?.parentSessionId === sessionId || parentFromMap === sessionId) { toClose.push(agentId); } } @@ -12228,6 +12321,7 @@ class ClaudemanApp { this.subagents.delete(agentId); this.subagentActivity.delete(agentId); this.subagentToolResults.delete(agentId); + this.subagentParentMap.delete(agentId); } } // Also clean up minimized agents for this session @@ -12251,6 +12345,17 @@ class ClaudemanApp { windowData.element.remove(); this.subagentWindows.delete(agentId); } + // Clean up teammate terminal if present + const termData = this.teammateTerminals.get(agentId); + if (termData) { + if (termData.resizeObserver) { + termData.resizeObserver.disconnect(); + } + if (termData.terminal) { + try { termData.terminal.dispose(); } catch {} + } + this.teammateTerminals.delete(agentId); + } } // Clean up ALL floating windows (called during handleInit to prevent memory leaks on reconnect) @@ -12268,6 +12373,16 @@ class ClaudemanApp { } this.subagentWindows.clear(); + // Clean up all teammate terminals + for (const [, termData] of this.teammateTerminals) { + if (termData.resizeObserver) termData.resizeObserver.disconnect(); + if (termData.terminal) { + try { termData.terminal.dispose(); } catch {} + } + } + this.teammateTerminals.clear(); + this.teammatePanesByName.clear(); + // Clean up all log viewer windows with their EventSources and drag listeners for (const [windowId, data] of this.logViewerWindows) { if (data.eventSource) { @@ -12460,6 +12575,9 @@ class ClaudemanApp { } renderSubagentWindowContent(agentId) { + // Skip if this window has a live terminal (don't overwrite xterm with activity HTML) + if (this.teammateTerminals.has(agentId)) return; + const body = document.getElementById(`subagent-window-body-${agentId}`); if (!body) return; @@ -12593,6 +12711,249 @@ class ClaudemanApp { // ========== Agent Teams ========== + /** Initialize an xterm.js terminal for a teammate's tmux pane */ + initTeammateTerminal(agentId, paneInfo, windowElement) { + const body = windowElement.querySelector('.subagent-window-body'); + if (!body) return; + + // Clear the activity log content + body.innerHTML = ''; + body.classList.add('teammate-terminal-body'); + windowElement.classList.add('has-terminal'); + + const sessionId = paneInfo.sessionId; + + // Buffer incoming terminal data until xterm is ready + const pendingData = []; + this.teammateTerminals.set(agentId, { + terminal: null, + fitAddon: null, + paneTarget: paneInfo.paneTarget, + sessionId, + resizeObserver: null, + pendingData, + }); + + // Defer terminal creation to next frame so the body element has computed dimensions + requestAnimationFrame(() => { + // Safety: if window was closed before we got here, bail out + if (!document.contains(body)) { + this.teammateTerminals.delete(agentId); + return; + } + + const terminal = new Terminal({ + theme: { + background: '#0d0d0d', + foreground: '#e0e0e0', + cursor: '#e0e0e0', + cursorAccent: '#0d0d0d', + selection: 'rgba(255, 255, 255, 0.3)', + black: '#0d0d0d', + red: '#ff6b6b', + green: '#51cf66', + yellow: '#ffd43b', + blue: '#339af0', + magenta: '#cc5de8', + cyan: '#22b8cf', + white: '#e0e0e0', + brightBlack: '#495057', + brightRed: '#ff8787', + brightGreen: '#69db7c', + brightYellow: '#ffe066', + brightBlue: '#5c7cfa', + brightMagenta: '#da77f2', + brightCyan: '#66d9e8', + brightWhite: '#ffffff', + }, + fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', + fontSize: 12, + lineHeight: 1.2, + cursorBlink: true, + cursorStyle: 'block', + scrollback: 5000, + allowTransparency: true, + }); + + const fitAddon = new FitAddon.FitAddon(); + terminal.loadAddon(fitAddon); + + try { + terminal.open(body); + } catch (err) { + console.warn('[TeammateTerminal] Failed to open terminal:', err); + this.teammateTerminals.delete(agentId); + return; + } + + // Wait for terminal renderer to fully initialize before any writes. + // xterm.js needs a few frames after open() before write() is safe. + setTimeout(() => { + try { fitAddon.fit(); } catch {} + + // Fetch initial pane buffer + fetch(`/api/sessions/${sessionId}/teammate-pane-buffer/${encodeURIComponent(paneInfo.paneTarget)}`) + .then(r => r.json()) + .then(resp => { + if (resp.success && resp.data?.buffer) { + try { terminal.write(resp.data.buffer); } catch {} + } + }) + .catch(err => console.error('[TeammateTerminal] Failed to fetch buffer:', err)); + + // Flush any data that arrived while terminal was initializing + for (const chunk of pendingData) { + try { terminal.write(chunk); } catch {} + } + pendingData.length = 0; + }, 100); + + // Forward keyboard input to the teammate's pane + terminal.onData((data) => { + fetch(`/api/sessions/${sessionId}/teammate-pane-input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paneTarget: paneInfo.paneTarget, input: data }), + }).catch(err => console.error('[TeammateTerminal] Failed to send input:', err)); + }); + + // Resize observer to refit terminal when window is resized + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { try { fitAddon.fit(); } catch {} }); + }); + resizeObserver.observe(body); + + // Update the stored entry with the real terminal + const entry = this.teammateTerminals.get(agentId); + if (entry) { + entry.terminal = terminal; + entry.fitAddon = fitAddon; + entry.resizeObserver = resizeObserver; + } + }); + } + + /** Open a standalone terminal window for a tmux-pane teammate (no subagent entry needed) */ + openTeammateTerminalWindow(paneData) { + // Only open if the session has a tab in Claudeman + if (!this.sessions.has(paneData.sessionId)) return; + + // Use pane target as the unique ID for this window + const windowId = `pane-${paneData.paneTarget}`; + + // If window already exists, focus it + if (this.subagentWindows.has(windowId)) { + const existing = this.subagentWindows.get(windowId); + if (existing.hidden) { + existing.element.style.display = 'flex'; + existing.hidden = false; + } + existing.element.style.zIndex = ++this.subagentWindowZIndex; + if (existing.minimized) { + this.restoreSubagentWindow(windowId); + } + return; + } + + // Calculate position + const windowCount = this.subagentWindows.size; + const windowWidth = 550; + const windowHeight = 400; + const gap = 20; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const startX = 50; + const startY = 120; + const maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1; + const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1; + const col = windowCount % maxCols; + const row = Math.floor(windowCount / maxCols) % maxRows; + let finalX = startX + col * (windowWidth + gap); + let finalY = startY + row * (windowHeight + gap); + finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10)); + finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10)); + + // Color badge + const colorClass = paneData.color || 'blue'; + + // Create window element + const win = document.createElement('div'); + win.className = 'subagent-window has-terminal'; + win.id = `subagent-window-${windowId}`; + win.style.zIndex = ++this.subagentWindowZIndex; + win.style.left = `${finalX}px`; + win.style.top = `${finalY}px`; + win.style.width = `${windowWidth}px`; + win.style.height = `${windowHeight}px`; + win.innerHTML = ` +

+
+ ⬤ + ${this.escapeHtml(paneData.teammateName)} + terminal +
+
+ +
+
+
+
+ `; + + document.body.appendChild(win); + + // Make draggable + const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); + + // Make resizable if method exists + if (typeof this.makeWindowResizable === 'function') { + this.makeWindowResizable(win); + } + + // Check visibility based on active session + const settings = this.loadAppSettingsFromStorage(); + const activeTabOnly = settings.subagentActiveTabOnly ?? true; + const shouldHide = activeTabOnly && paneData.sessionId !== this.activeSessionId; + + // Store reference + this.subagentWindows.set(windowId, { + element: win, + minimized: false, + hidden: shouldHide, + dragListeners, + description: `Teammate: ${paneData.teammateName}`, + }); + + // Also add to subagentParentMap for tab-based visibility + this.subagentParentMap.set(windowId, paneData.sessionId); + + if (shouldHide) { + win.style.display = 'none'; + } + + // Focus on click + win.addEventListener('mousedown', () => { + win.style.zIndex = ++this.subagentWindowZIndex; + }); + + // Resize observer for connection lines + const resizeObserver = new ResizeObserver(() => { + this.updateConnectionLines(); + }); + resizeObserver.observe(win); + this.subagentWindows.get(windowId).resizeObserver = resizeObserver; + + // Init the xterm.js terminal + this.initTeammateTerminal(windowId, paneData, win); + + // Animate in + requestAnimationFrame(() => { + win.style.transition = 'transform 0.3s ease, opacity 0.3s ease'; + win.style.transform = 'scale(1)'; + win.style.opacity = '1'; + }); + } + /** Rebuild the teammate lookup map from all team configs */ rebuildTeammateMap() { this.teammateMap.clear(); @@ -13625,7 +13986,7 @@ class ClaudemanApp { } } - // ========== Screen Sessions (in Monitor Panel) ========== + // ========== Mux Sessions (in Monitor Panel) ========== async loadScreens() { try { @@ -13974,7 +14335,7 @@ class ClaudemanApp { const body = document.getElementById('screenSessionsBody'); if (!this.screenSessions || this.screenSessions.length === 0) { - body.innerHTML = '
No screen sessions
'; + body.innerHTML = '
No mux sessions
'; return; } @@ -14049,7 +14410,7 @@ class ClaudemanApp { } async killScreen(sessionId) { - if (!confirm('Kill this screen session?')) return; + if (!confirm('Kill this mux session?')) return; try { await fetch(`/api/screens/${sessionId}`, { method: 'DELETE' }); diff --git a/src/web/public/index.html b/src/web/public/index.html index 9990ecb0..8913c275 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -201,7 +201,7 @@

Claudeman Terminal

-

Each instance opens a persistent GNU Screen session running Claude or Shell.

+

Each instance opens a persistent tmux session running Claude or Shell.

Sessions stay alive for autonomous work, even if you close this browser.