mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
chore: bump version to 0.1488
This commit is contained in:
+84
-144
@@ -1145,14 +1145,6 @@ class ClaudemanApp {
|
||||
// Once set, never recalculated. Persisted to localStorage and server.
|
||||
this.subagentParentMap = new Map();
|
||||
|
||||
// Agent Teams tracking
|
||||
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
|
||||
|
||||
@@ -2108,22 +2100,6 @@ class ClaudemanApp {
|
||||
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) {
|
||||
@@ -2892,81 +2868,6 @@ class ClaudemanApp {
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
});
|
||||
|
||||
// ========== Agent Teams Events ==========
|
||||
|
||||
addListener('team:created', (e) => {
|
||||
const team = JSON.parse(e.data);
|
||||
this.teams.set(team.name, team);
|
||||
this.rebuildTeammateMap();
|
||||
this.renderTeamTasksPanel();
|
||||
this.updateSubagentWindows();
|
||||
});
|
||||
|
||||
addListener('team:updated', (e) => {
|
||||
const team = JSON.parse(e.data);
|
||||
this.teams.set(team.name, team);
|
||||
this.rebuildTeammateMap();
|
||||
this.renderTeamTasksPanel();
|
||||
this.updateSubagentWindows();
|
||||
});
|
||||
|
||||
addListener('team:removed', (e) => {
|
||||
const team = JSON.parse(e.data);
|
||||
this.teams.delete(team.name);
|
||||
this.teamTasks.delete(team.name);
|
||||
this.rebuildTeammateMap();
|
||||
this.renderTeamTasksPanel();
|
||||
this.updateSubagentWindows();
|
||||
});
|
||||
|
||||
addListener('team:task_updated', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.teamTasks.set(data.teamName, data.tasks);
|
||||
this.renderTeamTasksPanel();
|
||||
});
|
||||
|
||||
addListener('team:inbox_message', (e) => {
|
||||
// Inbox messages currently just trigger panel updates
|
||||
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,
|
||||
});
|
||||
// Teammate pane windows are disabled — users interact with teammates
|
||||
// directly through the main tmux split-pane terminal instead.
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -11285,6 +11186,16 @@ class ClaudemanApp {
|
||||
}
|
||||
|
||||
renderSubagentPanel() {
|
||||
// Debounce renders at 150ms to prevent excessive DOM updates from rapid subagent events
|
||||
if (this._subagentPanelRenderTimeout) {
|
||||
clearTimeout(this._subagentPanelRenderTimeout);
|
||||
}
|
||||
this._subagentPanelRenderTimeout = setTimeout(() => {
|
||||
this._renderSubagentPanelImmediate();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
_renderSubagentPanelImmediate() {
|
||||
const list = this.$('subagentList');
|
||||
if (!list) return;
|
||||
|
||||
@@ -11777,6 +11688,17 @@ class ClaudemanApp {
|
||||
// This map stores agentId -> sessionId, where sessionId is the tab's data-id.
|
||||
|
||||
updateConnectionLines() {
|
||||
// Coalesce multiple calls within the same frame into a single rAF
|
||||
if (!this._connectionLinesScheduled) {
|
||||
this._connectionLinesScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._connectionLinesScheduled = false;
|
||||
this._updateConnectionLinesImmediate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_updateConnectionLinesImmediate() {
|
||||
const svg = document.getElementById('connectionLines');
|
||||
if (!svg) return;
|
||||
|
||||
@@ -12007,13 +11929,6 @@ class ClaudemanApp {
|
||||
// ========== Subagent Floating Windows ==========
|
||||
|
||||
openSubagentWindow(agentId) {
|
||||
// Skip teammate agents — they use tmux split panes in the main terminal,
|
||||
// not floating popup windows. Prevents false-info activity log popups.
|
||||
const agentForTeammateCheck = this.subagents.get(agentId);
|
||||
if (agentForTeammateCheck && this.getTeammateInfo(agentForTeammateCheck)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If window already exists, focus it
|
||||
if (this.subagentWindows.has(agentId)) {
|
||||
const existing = this.subagentWindows.get(agentId);
|
||||
@@ -12581,49 +12496,74 @@ class ClaudemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = activity.slice(-100).map(a => {
|
||||
const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false });
|
||||
if (a.type === 'tool') {
|
||||
return `<div class="activity-line">
|
||||
<span class="time">${time}</span>
|
||||
<span class="tool-icon">${this.getToolIcon(a.tool)}</span>
|
||||
<span class="tool-name">${a.tool}</span>
|
||||
<span class="tool-detail">${this.escapeHtml(this.getToolDetail(a.tool, a.input))}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'tool_result') {
|
||||
const icon = a.isError ? '❌' : '📄';
|
||||
const statusClass = a.isError ? ' error' : '';
|
||||
const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : '';
|
||||
const preview = a.preview.length > 60 ? a.preview.substring(0, 60) + '...' : a.preview;
|
||||
return `<div class="activity-line result-line${statusClass}">
|
||||
<span class="time">${time}</span>
|
||||
<span class="tool-icon">${icon}</span>
|
||||
<span class="tool-name">${a.tool || '→'}</span>
|
||||
<span class="tool-detail">${this.escapeHtml(preview)}${sizeInfo}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'progress') {
|
||||
// Check for hook events
|
||||
const isHook = a.hookEvent || a.hookName;
|
||||
const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓');
|
||||
const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType);
|
||||
return `<div class="activity-line progress-line${isHook ? ' hook-line' : ''}">
|
||||
<span class="time">${time}</span>
|
||||
<span class="tool-icon">${icon}</span>
|
||||
<span class="tool-detail">${this.escapeHtml(displayText)}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'message') {
|
||||
const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text;
|
||||
return `<div class="message-line">
|
||||
<span class="time">${time}</span> 💬 ${this.escapeHtml(preview)}
|
||||
</div>`;
|
||||
// Incremental rendering: track how many items are already rendered
|
||||
const renderedCount = body.dataset.renderedCount ? parseInt(body.dataset.renderedCount, 10) : 0;
|
||||
const maxItems = 100;
|
||||
const visibleActivity = activity.slice(-maxItems);
|
||||
|
||||
// If activity was trimmed or this is a fresh render, do full rebuild
|
||||
if (renderedCount === 0 || renderedCount > visibleActivity.length || body.children.length === 0 ||
|
||||
(body.children.length === 1 && body.querySelector('.subagent-empty'))) {
|
||||
// Full rebuild
|
||||
const html = visibleActivity.map(a => this._renderActivityItem(a)).join('');
|
||||
body.innerHTML = html;
|
||||
body.dataset.renderedCount = String(visibleActivity.length);
|
||||
} else {
|
||||
// Incremental: only append new items
|
||||
const newItems = visibleActivity.slice(renderedCount);
|
||||
if (newItems.length > 0) {
|
||||
const newHtml = newItems.map(a => this._renderActivityItem(a)).join('');
|
||||
body.insertAdjacentHTML('beforeend', newHtml);
|
||||
body.dataset.renderedCount = String(visibleActivity.length);
|
||||
|
||||
// Trim excess children from the front if over maxItems
|
||||
while (body.children.length > maxItems) {
|
||||
body.removeChild(body.firstChild);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
body.innerHTML = html;
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
|
||||
_renderActivityItem(a) {
|
||||
const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false });
|
||||
if (a.type === 'tool') {
|
||||
return `<div class="activity-line">
|
||||
<span class="time">${time}</span>
|
||||
<span class="tool-icon">${this.getToolIcon(a.tool)}</span>
|
||||
<span class="tool-name">${a.tool}</span>
|
||||
<span class="tool-detail">${this.escapeHtml(this.getToolDetail(a.tool, a.input))}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'tool_result') {
|
||||
const icon = a.isError ? '❌' : '📄';
|
||||
const statusClass = a.isError ? ' error' : '';
|
||||
const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : '';
|
||||
const preview = a.preview.length > 60 ? a.preview.substring(0, 60) + '...' : a.preview;
|
||||
return `<div class="activity-line result-line${statusClass}">
|
||||
<span class="time">${time}</span>
|
||||
<span class="tool-icon">${icon}</span>
|
||||
<span class="tool-name">${a.tool || '→'}</span>
|
||||
<span class="tool-detail">${this.escapeHtml(preview)}${sizeInfo}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'progress') {
|
||||
const isHook = a.hookEvent || a.hookName;
|
||||
const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓');
|
||||
const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType);
|
||||
return `<div class="activity-line progress-line${isHook ? ' hook-line' : ''}">
|
||||
<span class="time">${time}</span>
|
||||
<span class="tool-icon">${icon}</span>
|
||||
<span class="tool-detail">${this.escapeHtml(displayText)}</span>
|
||||
</div>`;
|
||||
} else if (a.type === 'message') {
|
||||
const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text;
|
||||
return `<div class="message-line">
|
||||
<span class="time">${time}</span> 💬 ${this.escapeHtml(preview)}
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Update all open subagent windows
|
||||
updateSubagentWindows() {
|
||||
for (const agentId of this.subagentWindows.keys()) {
|
||||
|
||||
@@ -425,21 +425,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Tasks Panel -->
|
||||
<div class="team-tasks-panel" id="teamTasksPanel" style="display: none;">
|
||||
<div class="team-tasks-header">
|
||||
<span class="team-tasks-header-text">Team Tasks</span>
|
||||
<div class="team-tasks-progress">
|
||||
<div class="team-tasks-progress-bar">
|
||||
<div class="team-tasks-progress-fill"></div>
|
||||
</div>
|
||||
<span class="team-tasks-progress-text">0/0</span>
|
||||
</div>
|
||||
<button class="team-tasks-close" onclick="app.hideTeamTasksPanel()" title="Hide">─</button>
|
||||
</div>
|
||||
<div class="team-tasks-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Session Options Modal (includes Respawn Settings) -->
|
||||
<div class="modal" id="sessionOptionsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
|
||||
|
||||
@@ -5317,20 +5317,6 @@ 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;
|
||||
@@ -5499,187 +5485,6 @@ kbd {
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ========== Agent Teams (Teammate Badges + Team Tasks Panel) ========== */
|
||||
|
||||
/* Teammate badge in subagent panel/windows */
|
||||
.teammate-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.teammate-color-blue {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.teammate-color-green {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.teammate-color-yellow {
|
||||
background: #eab308;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Teammate dot icon replacing robot emoji */
|
||||
.teammate-dot {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.teammate-dot.teammate-color-blue { color: #3b82f6; }
|
||||
.teammate-dot.teammate-color-green { color: #22c55e; }
|
||||
.teammate-dot.teammate-color-yellow { color: #eab308; }
|
||||
|
||||
/* Subagent items that are teammates get a subtle left border */
|
||||
.subagent-item.is-teammate {
|
||||
border-left: 2px solid #3b82f6;
|
||||
}
|
||||
.subagent-item.is-teammate .teammate-color-green ~ * { border-left-color: #22c55e; }
|
||||
|
||||
/* Team Tasks Panel */
|
||||
.team-tasks-panel {
|
||||
position: fixed;
|
||||
width: 360px;
|
||||
min-width: 260px;
|
||||
min-height: 120px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 900;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
/* Resize handle indicator */
|
||||
.team-tasks-panel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: linear-gradient(135deg, transparent 50%, var(--border-light) 50%, var(--border-light) 60%, transparent 60%, transparent 70%, var(--border-light) 70%, var(--border-light) 80%, transparent 80%);
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.team-tasks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: move;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.team-tasks-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.team-tasks-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.team-tasks-header-text {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.team-tasks-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.team-tasks-progress-bar {
|
||||
width: 60px;
|
||||
height: 6px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.team-tasks-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--success);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.team-tasks-progress-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.team-tasks-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.team-task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.team-task-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.team-task-item.in-progress .team-task-status {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.team-task-status {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.team-task-subject {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team-task-owner {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.team-task-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ========== Project Insights Panel (Bash File Viewers) ========== */
|
||||
|
||||
.project-insights-panel {
|
||||
|
||||
@@ -140,14 +140,3 @@ 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),
|
||||
});
|
||||
|
||||
+13
-313
@@ -36,7 +36,6 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { createRequire } from 'node:module';
|
||||
import { RunSummaryTracker } from '../run-summary.js';
|
||||
import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js';
|
||||
import { TeamWatcher } from '../team-watcher.js';
|
||||
|
||||
// Load version from package.json
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -54,9 +53,6 @@ import {
|
||||
type PersistedRespawnConfig,
|
||||
type NiceConfig,
|
||||
type ImageDetectedEvent,
|
||||
type TeamConfig,
|
||||
type TeamTask,
|
||||
type InboxMessage,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
} from '../types.js';
|
||||
import {
|
||||
@@ -69,7 +65,6 @@ import {
|
||||
HookEventSchema,
|
||||
ConfigUpdateSchema,
|
||||
RespawnConfigSchema,
|
||||
TeammatePaneInputSchema,
|
||||
} from './schemas.js';
|
||||
import { StaleExpirationMap } from '../utils/index.js';
|
||||
|
||||
@@ -377,11 +372,6 @@ export class WebServer extends EventEmitter {
|
||||
detected: (event: ImageDetectedEvent) => void;
|
||||
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();
|
||||
this.port = port;
|
||||
@@ -504,176 +494,6 @@ 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
|
||||
const visibleTasks = data.tasks.filter(t => !t.metadata?._internal);
|
||||
this.broadcast('team:task_updated', { teamName: data.teamName, tasks: visibleTasks });
|
||||
});
|
||||
this.teamWatcher.on('inboxMessage', (data: { teamName: string; member: string; message: InboxMessage }) => {
|
||||
this.broadcast('team:inbox_message', data);
|
||||
});
|
||||
}
|
||||
|
||||
/** 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, {
|
||||
@@ -697,18 +517,6 @@ 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);
|
||||
});
|
||||
@@ -843,7 +651,7 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
|
||||
// Session management
|
||||
this.app.get('/api/sessions', async () => this.getSessionsState());
|
||||
this.app.get('/api/sessions', async () => this.getLightSessionsState());
|
||||
|
||||
this.app.post('/api/sessions', async (req): Promise<SessionResponse> => {
|
||||
// Prevent unbounded session creation
|
||||
@@ -3503,106 +3311,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
});
|
||||
|
||||
|
||||
// ========== Agent Teams ==========
|
||||
|
||||
// List all active teams
|
||||
this.app.get('/api/teams', async () => {
|
||||
const teams = this.teamWatcher.getTeams();
|
||||
// Enrich with session association
|
||||
const enriched = teams.map(team => ({
|
||||
...team,
|
||||
sessionExists: this.sessions.has(team.leadSessionId),
|
||||
activeTasks: this.teamWatcher.getActiveTaskCount(team.name),
|
||||
teammateCount: team.members.filter(m => m.agentType !== 'team-lead').length,
|
||||
}));
|
||||
return { success: true, data: enriched };
|
||||
});
|
||||
|
||||
// Get team for a specific session
|
||||
this.app.get('/api/sessions/:id/team', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const team = this.teamWatcher.getTeamForSession(id);
|
||||
if (!team) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...team,
|
||||
activeTasks: this.teamWatcher.getActiveTaskCount(team.name),
|
||||
teammateCount: team.members.filter(m => m.agentType !== 'team-lead').length,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Get team tasks for a session's team
|
||||
this.app.get('/api/sessions/:id/team-tasks', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const team = this.teamWatcher.getTeamForSession(id);
|
||||
if (!team) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const tasks = this.teamWatcher.getTeamTasks(team.name);
|
||||
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) => {
|
||||
@@ -3829,14 +3537,6 @@ 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) {
|
||||
@@ -4227,9 +3927,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||||
// Inject team watcher for team-aware idle detection
|
||||
controller.setTeamWatcher(this.teamWatcher);
|
||||
|
||||
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
||||
const getTracker = () => this.runSummaryTrackers.get(sessionId);
|
||||
|
||||
@@ -5037,10 +4734,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
console.log('Image watcher disabled by user settings');
|
||||
}
|
||||
|
||||
// Start team watcher for Agent Teams support
|
||||
this.setupTeamWatcherListeners();
|
||||
this.teamWatcher.start();
|
||||
console.log('Team watcher started - monitoring ~/.claude/teams for agent team activity');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5336,14 +5029,21 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Stop image watcher
|
||||
imageWatcher.stop();
|
||||
|
||||
// Stop team watcher and clean up pane streams
|
||||
this.cleanupAllPaneStreams();
|
||||
this.teamWatcher.removeAllListeners();
|
||||
this.teamWatcher.stop();
|
||||
|
||||
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
|
||||
fileStreamManager.destroy();
|
||||
|
||||
// Stop all remaining tracked resources before clearing their Maps
|
||||
for (const tracker of this.runSummaryTrackers.values()) {
|
||||
tracker.stop();
|
||||
}
|
||||
for (const watcher of this.transcriptWatchers.values()) {
|
||||
watcher.removeAllListeners();
|
||||
watcher.stop();
|
||||
}
|
||||
for (const orchestrator of this.activePlanOrchestrators.values()) {
|
||||
orchestrator.cancel();
|
||||
}
|
||||
|
||||
// Clear remaining Maps that accumulate session references
|
||||
this.respawnTimers.clear();
|
||||
this.runSummaryTrackers.clear();
|
||||
|
||||
Reference in New Issue
Block a user