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 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-12 13:44:07 +01:00
co-authored by Claude Opus 4.6
parent 59a8f6bd7d
commit a4e778c7c4
22 changed files with 2589 additions and 102 deletions
+5 -5
View File
@@ -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;
+4 -4
View File
@@ -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)
+33 -18
View File
@@ -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<string, InboxMessage[]> = new LRUMap({ maxSize: MAX_CACHED_TASKS });
// Track config mtimes to avoid re-reading unchanged files
private configMtimes: Map<string, number> = new Map();
private taskMtimes: Map<string, number> = new Map();
private taskMtimes: Map<string, string> = new Map();
private inboxMtimes: Map<string, number> = 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<TeamMember & { tmuxPaneId: string }> {
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<TeamConfig>(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;
}
+2 -2
View File
@@ -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
+215 -1
View File
@@ -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';
}
+22 -3
View File
@@ -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-<id>) */
/** Mux session name (claudeman-<id>) */
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 ==========
/**
+371 -10
View File
@@ -1149,6 +1149,10 @@ class ClaudemanApp {
this.teams = new Map(); // Map<teamName, TeamConfig>
this.teamTasks = new Map(); // Map<teamName, TeamTask[]>
this.teammateMap = new Map(); // Map<agentId-prefix, {name, color, teamName}> for quick lookup
// Teammate tmux pane terminals
this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }>
this.teammateTerminals = new Map(); // Map<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }>
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 = `
<div class="subagent-window-header">
<div class="subagent-window-title" title="Teammate terminal: ${this.escapeHtml(paneData.teammateName)} (pane ${paneData.paneTarget})">
<span class="icon" style="color: var(--team-color-${colorClass}, #339af0)">⬤</span>
<span class="id">${this.escapeHtml(paneData.teammateName)}</span>
<span class="status running">terminal</span>
</div>
<div class="subagent-window-actions">
<button onclick="app.closeSubagentWindow('${windowId}')" title="Minimize to tab">─</button>
</div>
</div>
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
</div>
`;
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 = '<div class="monitor-empty">No screen sessions</div>';
body.innerHTML = '<div class="monitor-empty">No mux sessions</div>';
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' });
+1 -1
View File
@@ -201,7 +201,7 @@
<div class="welcome-overlay" id="welcomeOverlay">
<div class="welcome-content">
<h1 class="welcome-title">Claudeman Terminal</h1>
<p class="welcome-desc">Each instance opens a persistent GNU Screen session running Claude or Shell.</p>
<p class="welcome-desc">Each instance opens a persistent tmux session running Claude or Shell.</p>
<p class="welcome-desc">Sessions stay alive for autonomous work, even if you close this browser.</p>
<div class="welcome-actions">
<button class="welcome-btn welcome-btn-primary" onclick="app.runClaude()">
+14
View File
@@ -5317,6 +5317,20 @@ kbd {
scroll-behavior: smooth;
}
/* Teammate terminal pane (xterm.js embedded in subagent window) */
.subagent-window-body.teammate-terminal-body {
padding: 0 !important;
overflow: hidden !important;
}
.subagent-window-body.teammate-terminal-body .xterm {
height: 100%;
}
/* Larger default size for terminal windows */
.subagent-window.has-terminal {
width: 550px;
height: 400px;
}
/* Custom scrollbar for subagent window body */
.subagent-window-body::-webkit-scrollbar {
width: 6px;
+12 -1
View File
@@ -79,7 +79,7 @@ export const QuickStartSchema = z.object({
export const HookEventSchema = z.object({
event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop', 'teammate_idle', 'task_completed']),
sessionId: z.string().min(1),
data: z.unknown().optional(),
data: z.record(z.string(), z.unknown()).optional(),
});
// ========== Configuration ==========
@@ -140,3 +140,14 @@ export const SessionInputWithLimitSchema = z.object({
input: z.string().max(100000), // 100KB max input
useScreen: z.boolean().optional(),
});
// ========== Teammate Pane Routes ==========
/**
* Schema for POST /api/sessions/:id/teammate-pane-input
* Sends keyboard input to a teammate's tmux pane.
*/
export const TeammatePaneInputSchema = z.object({
paneTarget: z.string().regex(/^(%\d+|\d+)$/, 'Invalid pane target format'),
input: z.string().max(100000),
});
+251 -18
View File
@@ -14,15 +14,16 @@ import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
import fastifyStatic from '@fastify/static';
import path, { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readdirSync, readFileSync, rmSync, unlinkSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
import { execSync, spawn, type ChildProcess } from 'node:child_process';
import { homedir, tmpdir, totalmem, freemem, loadavg, cpus } from 'node:os';
import { EventEmitter } from 'node:events';
import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool } from '../session.js';
import { fileStreamManager } from '../file-stream-manager.js';
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import { TmuxManager } from '../tmux-manager.js';
import { createMultiplexer } from '../mux-factory.js';
import { getStore } from '../state-store.js';
import { generateClaudeMd } from '../templates/claude-md.js';
@@ -68,6 +69,7 @@ import {
HookEventSchema,
ConfigUpdateSchema,
RespawnConfigSchema,
TeammatePaneInputSchema,
} from './schemas.js';
import { StaleExpirationMap } from '../utils/index.js';
@@ -376,6 +378,9 @@ export class WebServer extends EventEmitter {
error: (error: Error, sessionId?: string) => void;
} | null = null;
private teamWatcher: TeamWatcher = new TeamWatcher();
// Pane streaming for teammate tmux panes: keyed by "sessionId:paneTarget"
private paneStreams: Map<string, { tailProcess: ChildProcess; outputFile: string; muxName: string; paneTarget: string; sessionId: string; teammateName: string }> = new Map();
private paneStreamTempDir: string | null = null;
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
super();
@@ -502,12 +507,15 @@ export class WebServer extends EventEmitter {
private setupTeamWatcherListeners(): void {
this.teamWatcher.on('teamCreated', (team: TeamConfig) => {
this.broadcast('team:created', team);
this.startPaneStreamsForTeam(team);
});
this.teamWatcher.on('teamUpdated', (team: TeamConfig) => {
this.broadcast('team:updated', team);
this.startPaneStreamsForTeam(team);
});
this.teamWatcher.on('teamRemoved', (team: TeamConfig) => {
this.broadcast('team:removed', team);
this.stopPaneStreamsForSession(team.leadSessionId);
});
this.teamWatcher.on('taskUpdated', (data: { teamName: string; tasks: TeamTask[] }) => {
// Filter out internal tasks for broadcast
@@ -519,6 +527,153 @@ export class WebServer extends EventEmitter {
});
}
/** Start pane streams for any tmux-pane teammates in a team */
private startPaneStreamsForTeam(team: TeamConfig): void {
if (this.mux.backend !== 'tmux') return;
const tmuxMgr = this.mux as TmuxManager;
// Find the mux session for this team's lead
const muxSession = tmuxMgr.getSession(team.leadSessionId);
if (!muxSession) return;
// Enable mouse mode so users can click between panes in the main terminal
tmuxMgr.enableMouseMode(muxSession.muxName);
let paneTeammates = this.teamWatcher.getTmuxPaneTeammates(team.name);
// Auto-discover panes if config.json doesn't include tmuxPaneId.
// When Claude Code creates agent team split panes in tmux mode, the panes
// exist in the tmux session but config.json may not include pane IDs.
// We discover them by querying tmux and matching to non-lead teammates.
if (paneTeammates.length === 0) {
const nonLeadMembers = team.members.filter(m => m.agentType !== 'team-lead');
if (nonLeadMembers.length === 0) return;
const panes = tmuxMgr.listPanes(muxSession.muxName);
// Skip pane index 0 (leader's pane) — remaining panes are teammates
const teammatePanes = panes.filter(p => p.paneIndex > 0).sort((a, b) => a.paneIndex - b.paneIndex);
if (teammatePanes.length > 0) {
// Match discovered panes to teammates in order
const discovered: Array<typeof team.members[0] & { tmuxPaneId: string }> = [];
for (let i = 0; i < Math.min(teammatePanes.length, nonLeadMembers.length); i++) {
const paneId = teammatePanes[i].paneId; // e.g., "%1"
const member = nonLeadMembers[i];
// Write back to member for future lookups
member.tmuxPaneId = paneId;
discovered.push({ ...member, tmuxPaneId: paneId });
}
paneTeammates = discovered;
console.log(`[Server] Auto-discovered ${discovered.length} teammate panes via tmux list-panes`);
}
}
if (paneTeammates.length === 0) return;
// Ensure temp directory exists
if (!this.paneStreamTempDir) {
this.paneStreamTempDir = mkdtempSync(join(tmpdir(), 'claudeman-panes-'));
}
for (const teammate of paneTeammates) {
const streamKey = `${team.leadSessionId}:${teammate.tmuxPaneId}`;
// Skip if stream already exists
if (this.paneStreams.has(streamKey)) continue;
const outputFile = join(this.paneStreamTempDir, `pane-${teammate.name}-${Date.now()}.raw`);
// Create the output file so tail -f doesn't error
writeFileSync(outputFile, '');
// Start tmux pipe-pane to redirect pane output to the file
const started = tmuxMgr.startPipePane(muxSession.muxName, teammate.tmuxPaneId, outputFile);
if (!started) {
console.error(`[Server] Failed to start pipe-pane for teammate ${teammate.name}`);
continue;
}
// Start tail -f to stream the output file contents
const tailProcess = spawn('tail', ['-f', '-n', '0', outputFile], {
stdio: ['ignore', 'pipe', 'ignore'],
});
tailProcess.stdout?.on('data', (chunk: Buffer) => {
this.broadcast('teammate:terminal', {
sessionId: team.leadSessionId,
teammateName: teammate.name,
paneTarget: teammate.tmuxPaneId,
data: chunk.toString('base64'),
});
});
tailProcess.on('error', (err) => {
console.error(`[Server] Pane stream tail error for ${teammate.name}:`, err.message);
});
this.paneStreams.set(streamKey, {
tailProcess,
outputFile,
muxName: muxSession.muxName,
paneTarget: teammate.tmuxPaneId,
sessionId: team.leadSessionId,
teammateName: teammate.name,
});
// Notify frontend that a pane is available
this.broadcast('teammate:pane_available', {
sessionId: team.leadSessionId,
teammateName: teammate.name,
paneTarget: teammate.tmuxPaneId,
color: teammate.color || 'blue',
});
console.log(`[Server] Started pane stream for teammate ${teammate.name} (pane ${teammate.tmuxPaneId})`);
}
}
/** Stop all pane streams for a session */
private stopPaneStreamsForSession(sessionId: string): void {
if (this.mux.backend !== 'tmux') return;
const tmuxMgr = this.mux as TmuxManager;
for (const [key, stream] of this.paneStreams) {
if (stream.sessionId === sessionId) {
// Kill the tail process
try { stream.tailProcess.kill('SIGTERM'); } catch { /* already dead */ }
// Stop tmux pipe-pane
tmuxMgr.stopPipePane(stream.muxName, stream.paneTarget);
// Remove output file
try { unlinkSync(stream.outputFile); } catch { /* ignore */ }
this.paneStreams.delete(key);
}
}
}
/** Stop all pane streams and clean up temp directory */
private cleanupAllPaneStreams(): void {
if (this.mux.backend === 'tmux') {
const tmuxMgr = this.mux as TmuxManager;
for (const [, stream] of this.paneStreams) {
try { stream.tailProcess.kill('SIGTERM'); } catch { /* already dead */ }
tmuxMgr.stopPipePane(stream.muxName, stream.paneTarget);
try { unlinkSync(stream.outputFile); } catch { /* ignore */ }
}
} else {
for (const [, stream] of this.paneStreams) {
try { stream.tailProcess.kill('SIGTERM'); } catch { /* already dead */ }
try { unlinkSync(stream.outputFile); } catch { /* ignore */ }
}
}
this.paneStreams.clear();
// Remove temp directory
if (this.paneStreamTempDir) {
try { rmSync(this.paneStreamTempDir, { recursive: true, force: true }); } catch { /* ignore */ }
this.paneStreamTempDir = null;
}
}
private async setupRoutes(): Promise<void> {
// Serve static files
await this.app.register(fastifyStatic, {
@@ -542,6 +697,18 @@ export class WebServer extends EventEmitter {
// Buffers are fetched on-demand when switching tabs
this.sendSSE(reply, 'init', this.getLightState());
// Send pane_available events for any active pane streams
for (const stream of this.paneStreams.values()) {
const team = this.teamWatcher?.getTeamForSession(stream.sessionId);
const member = team?.members.find(m => m.name === stream.teammateName);
this.sendSSE(reply, 'teammate:pane_available', {
sessionId: stream.sessionId,
teammateName: stream.teammateName,
paneTarget: stream.paneTarget,
color: member?.color || 'blue',
});
}
req.raw.on('close', () => {
this.sseClients.delete(reply);
});
@@ -1548,7 +1715,7 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
}
// Use writeViaScreen for programmatic input (more reliable for screen sessions)
// Use writeViaScreen for programmatic input (more reliable for mux sessions)
let success = false;
if (useScreen) {
success = session.writeViaScreen(inputStr);
@@ -1697,7 +1864,7 @@ export class WebServer extends EventEmitter {
controller.start();
// Persist respawn config to screen session and state.json
// Persist respawn config to mux session and state.json
this.saveRespawnConfig(id, controller.getConfig());
this.persistSessionState(session);
@@ -1888,7 +2055,7 @@ export class WebServer extends EventEmitter {
this.setupTimedRespawn(id, body.durationMinutes);
}
// Persist respawn config to screen session and state.json
// Persist respawn config to mux session and state.json
this.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes);
this.persistSessionState(session);
@@ -3155,7 +3322,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return {
success: true,
nice: session.niceConfig,
note: 'Nice priority only affects newly created screen sessions, not currently running ones.',
note: 'Nice priority only affects newly created mux sessions, not currently running ones.',
};
});
@@ -3231,7 +3398,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
};
});
// Kill a screen session
// Kill a mux session
this.app.delete('/api/screens/:sessionId', async (req) => {
const { sessionId } = req.params as { sessionId: string };
const success = await this.mux.killSession(sessionId);
@@ -3379,6 +3546,63 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return { success: true, data: tasks };
});
// ========== Teammate Pane Endpoints ==========
// List teammate panes for a session
this.app.get('/api/sessions/:id/teammate-panes', async (req) => {
const { id } = req.params as { id: string };
const team = this.teamWatcher.getTeamForSession(id);
if (!team) {
return { success: true, data: [] };
}
const paneTeammates = this.teamWatcher.getTmuxPaneTeammates(team.name);
const panes = paneTeammates.map(t => ({
teammateName: t.name,
paneTarget: t.tmuxPaneId,
color: t.color || 'blue',
hasStream: this.paneStreams.has(`${id}:${t.tmuxPaneId}`),
}));
return { success: true, data: panes };
});
// Get pane buffer for a teammate
this.app.get('/api/sessions/:id/teammate-pane-buffer/:paneTarget', async (req) => {
const { id, paneTarget } = req.params as { id: string; paneTarget: string };
if (this.mux.backend !== 'tmux') {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Pane access requires tmux backend');
}
const tmuxMgr = this.mux as TmuxManager;
const muxSession = tmuxMgr.getSession(id);
if (!muxSession) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const buffer = tmuxMgr.capturePaneBuffer(muxSession.muxName, paneTarget);
if (buffer === null) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to capture pane buffer');
}
return { success: true, data: { buffer } };
});
// Send input to a teammate's pane
this.app.post('/api/sessions/:id/teammate-pane-input', async (req) => {
const { id } = req.params as { id: string };
const result = TeammatePaneInputSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { paneTarget, input } = result.data;
if (this.mux.backend !== 'tmux') {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Pane access requires tmux backend');
}
const tmuxMgr = this.mux as TmuxManager;
const muxSession = tmuxMgr.getSession(id);
if (!muxSession) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const success = tmuxMgr.sendInputToPane(muxSession.muxName, paneTarget, input);
return { success };
});
// ========== Hook Events ==========
this.app.post('/api/hook-event', async (req) => {
@@ -3407,7 +3631,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
// Start transcript watching if transcript_path is provided
if (data && typeof data === 'object' && 'transcript_path' in data) {
if (data && 'transcript_path' in data) {
const transcriptPath = String(data.transcript_path);
if (transcriptPath) {
this.startTranscriptWatcher(sessionId, transcriptPath);
@@ -3415,7 +3639,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
// Sanitize forwarded data: only include known safe fields, limit size
const safeData = sanitizeHookData(data as Record<string, unknown> | undefined);
const safeData = sanitizeHookData(data);
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
// Track in run summary
@@ -3514,7 +3738,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.store.setSession(session.id, state);
}
// Helper to save respawn config to screen session for persistence
// Helper to save respawn config to mux session for persistence
private saveRespawnConfig(sessionId: string, config: RespawnConfig, durationMinutes?: number): void {
const persistedConfig: PersistedRespawnConfig = {
enabled: config.enabled,
@@ -3605,6 +3829,14 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
}
// Clean up any agent team associated with this session
// Stop pane streams and notify frontend so team tasks/windows are removed
this.stopPaneStreamsForSession(sessionId);
const team = this.teamWatcher.getTeamForSession(sessionId);
if (team) {
this.broadcast('team:removed', team);
}
// Stop and remove respawn controller - but save config first for restart recovery
const controller = this.respawnControllers.get(sessionId);
if (controller) {
@@ -4711,7 +4943,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
for (const client of this.sseClients) {
try {
// Check if the underlying socket is still writable
const socket = client.raw.socket || (client.raw as any).connection;
const socket = client.raw.socket;
if (!socket || socket.destroyed || !socket.writable) {
deadClients.push(client);
} else {
@@ -4757,7 +4989,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
async start(): Promise<void> {
await this.setupRoutes();
// Restore screen sessions BEFORE accepting connections
// Restore mux sessions BEFORE accepting connections
// This prevents race conditions where clients connect before state is ready
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
if (!this.testMode) {
@@ -4853,11 +5085,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
const { alive, dead, discovered } = await this.mux.reconcileSessions();
if (discovered.length > 0) {
console.log(`[Server] Discovered ${discovered.length} unknown screen session(s)`);
console.log(`[Server] Discovered ${discovered.length} unknown mux session(s)`);
}
if (alive.length > 0 || discovered.length > 0) {
console.log(`[Server] Found ${alive.length + discovered.length} alive screen session(s) from previous run`);
console.log(`[Server] Found ${alive.length + discovered.length} alive mux session(s) from previous run`);
// For each alive screen, create a Session object if it doesn't exist
const screens = this.mux.getSessions();
@@ -4996,10 +5228,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
if (dead.length > 0) {
console.log(`[Server] Cleaned up ${dead.length} dead screen session(s)`);
console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`);
}
} catch (err) {
console.error('[Server] Failed to restore screen sessions:', err);
console.error('[Server] Failed to restore mux sessions:', err);
}
}
@@ -5104,7 +5336,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Stop image watcher
imageWatcher.stop();
// Stop team watcher
// Stop team watcher and clean up pane streams
this.cleanupAllPaneStreams();
this.teamWatcher.removeAllListeners();
this.teamWatcher.stop();