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
+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();