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:
-**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:
@@ -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 = `
+