mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
chore: bump version to 0.1479
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+374
-15
@@ -1090,6 +1090,11 @@ class ClaudemanApp {
|
||||
// This is the SINGLE SOURCE OF TRUTH for which tab an agent window connects to.
|
||||
// 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
|
||||
this.ralphStatePanelCollapsed = true; // Default to collapsed
|
||||
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
|
||||
|
||||
@@ -1153,6 +1158,11 @@ class ClaudemanApp {
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.isOnline = navigator.onLine;
|
||||
|
||||
// Offline input queue
|
||||
this._inputQueue = new Map(); // Map<sessionId, string>
|
||||
this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session
|
||||
this._connectionStatus = 'connected';
|
||||
|
||||
// Accessibility: Focus trap for modals
|
||||
this.activeFocusTrap = null;
|
||||
|
||||
@@ -1456,18 +1466,34 @@ class ClaudemanApp {
|
||||
this._inputFlushTimeout = null;
|
||||
this._inputFlushDelay = 16; // Flush at 60fps max
|
||||
|
||||
const flushInput = () => {
|
||||
const flushInput = async () => {
|
||||
if (this._pendingInput && this.activeSessionId) {
|
||||
const input = this._pendingInput;
|
||||
const sessionId = this.activeSessionId;
|
||||
this._pendingInput = '';
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input })
|
||||
});
|
||||
// Clear pending hooks when user sends input (they've addressed the prompt)
|
||||
this.clearPendingHooks(sessionId);
|
||||
|
||||
// Queue immediately if offline
|
||||
if (!this.isOnline || this._connectionStatus === 'disconnected') {
|
||||
this._enqueueInput(sessionId, input);
|
||||
this._inputFlushTimeout = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this._enqueueInput(sessionId, input);
|
||||
} else {
|
||||
// Clear pending hooks when user sends input (they've addressed the prompt)
|
||||
this.clearPendingHooks(sessionId);
|
||||
}
|
||||
} catch {
|
||||
this._enqueueInput(sessionId, input);
|
||||
}
|
||||
}
|
||||
this._inputFlushTimeout = null;
|
||||
};
|
||||
@@ -2010,6 +2036,7 @@ class ClaudemanApp {
|
||||
this.saveSessionOrder();
|
||||
}
|
||||
this.terminalBuffers.delete(data.id);
|
||||
this._inputQueue.delete(data.id); // Clean up queued offline input for this session
|
||||
this.ralphStates.delete(data.id); // Clean up ralph state for this session
|
||||
this.ralphClosedSessions.delete(data.id); // Clean up closed tracking for this session
|
||||
this.projectInsights.delete(data.id); // Clean up project insights for this session
|
||||
@@ -2778,6 +2805,44 @@ 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();
|
||||
});
|
||||
|
||||
// ========== Image Detection Events (Screenshots & Generated Images) ==========
|
||||
|
||||
addListener('image:detected', (e) => {
|
||||
@@ -2851,8 +2916,84 @@ class ClaudemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
setConnectionStatus(_status) {
|
||||
// Connection status UI removed - method kept for compatibility
|
||||
setConnectionStatus(status) {
|
||||
this._connectionStatus = status;
|
||||
this._updateConnectionIndicator();
|
||||
if (status === 'connected' && this._inputQueue.size > 0) {
|
||||
this._drainInputQueues();
|
||||
}
|
||||
}
|
||||
|
||||
_enqueueInput(sessionId, input) {
|
||||
const existing = this._inputQueue.get(sessionId) || '';
|
||||
let combined = existing + input;
|
||||
// Enforce 64KB cap — keep most recent keystrokes
|
||||
if (combined.length > this._inputQueueMaxBytes) {
|
||||
combined = combined.slice(combined.length - this._inputQueueMaxBytes);
|
||||
}
|
||||
this._inputQueue.set(sessionId, combined);
|
||||
this._updateConnectionIndicator();
|
||||
}
|
||||
|
||||
async _drainInputQueues() {
|
||||
if (this._inputQueue.size === 0) return;
|
||||
// Snapshot and clear
|
||||
const queued = new Map(this._inputQueue);
|
||||
this._inputQueue.clear();
|
||||
this._updateConnectionIndicator();
|
||||
|
||||
for (const [sessionId, input] of queued) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this._enqueueInput(sessionId, input);
|
||||
}
|
||||
} catch {
|
||||
this._enqueueInput(sessionId, input);
|
||||
}
|
||||
}
|
||||
this._updateConnectionIndicator();
|
||||
}
|
||||
|
||||
_updateConnectionIndicator() {
|
||||
const indicator = this.$('connectionIndicator');
|
||||
const dot = this.$('connectionDot');
|
||||
const text = this.$('connectionText');
|
||||
if (!indicator || !dot || !text) return;
|
||||
|
||||
let totalBytes = 0;
|
||||
for (const v of this._inputQueue.values()) totalBytes += v.length;
|
||||
|
||||
const status = this._connectionStatus;
|
||||
const hasQueue = totalBytes > 0;
|
||||
|
||||
// Connected with empty queue — hide
|
||||
if ((status === 'connected' || status === 'connecting') && !hasQueue) {
|
||||
indicator.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
indicator.style.display = 'flex';
|
||||
dot.className = 'connection-dot';
|
||||
|
||||
const formatBytes = (b) => b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`;
|
||||
|
||||
if (status === 'connected' && hasQueue) {
|
||||
// Draining
|
||||
dot.classList.add('draining');
|
||||
text.textContent = `Sending ${formatBytes(totalBytes)}...`;
|
||||
} else if (status === 'reconnecting') {
|
||||
dot.classList.add('reconnecting');
|
||||
text.textContent = hasQueue ? `Reconnecting (${formatBytes(totalBytes)} queued)` : 'Reconnecting...';
|
||||
} else {
|
||||
// Offline or disconnected
|
||||
dot.classList.add('offline');
|
||||
text.textContent = hasQueue ? `Offline (${formatBytes(totalBytes)} queued)` : 'Offline';
|
||||
}
|
||||
}
|
||||
|
||||
setupOnlineDetection() {
|
||||
@@ -3543,6 +3684,18 @@ class ClaudemanApp {
|
||||
async selectSession(sessionId) {
|
||||
if (this.activeSessionId === sessionId) return;
|
||||
|
||||
// Instant visual feedback — works even with no network.
|
||||
// Appended to .main (not terminalContainer) so xterm DOM resets can't remove it.
|
||||
const main = document.querySelector('.main');
|
||||
if (main) {
|
||||
const prev = main.querySelector('.tab-switch-glow');
|
||||
if (prev) prev.remove();
|
||||
const glow = document.createElement('div');
|
||||
glow.className = 'tab-switch-glow';
|
||||
glow.addEventListener('animationend', () => glow.remove(), { once: true });
|
||||
main.appendChild(glow);
|
||||
}
|
||||
|
||||
// Clean up flicker filter state when switching sessions
|
||||
if (this.flickerFilterTimeout) {
|
||||
clearTimeout(this.flickerFilterTimeout);
|
||||
@@ -3565,6 +3718,13 @@ class ClaudemanApp {
|
||||
this.clearPendingHooks(sessionId, 'idle_prompt');
|
||||
this.renderSessionTabs();
|
||||
|
||||
// Glow the newly-active tab
|
||||
const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`);
|
||||
if (activeTab) {
|
||||
activeTab.classList.add('tab-glow');
|
||||
activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true });
|
||||
}
|
||||
|
||||
// Check if this is a restored session that needs to be attached
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
@@ -6900,13 +7060,22 @@ class ClaudemanApp {
|
||||
sessionNames.push(`w${startNumber + i}-${caseName}`);
|
||||
}
|
||||
|
||||
// Build env overrides from global + case settings (case overrides global)
|
||||
const caseSettings = this.getCaseSettings(caseName);
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
const envOverrides = {};
|
||||
if (caseSettings.agentTeams || globalSettings.agentTeamsEnabled) {
|
||||
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
||||
}
|
||||
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
||||
|
||||
// Step 1: Create all sessions in parallel
|
||||
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
||||
const createPromises = sessionNames.map(name =>
|
||||
fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name })
|
||||
body: JSON.stringify({ workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}) })
|
||||
}).then(r => r.json())
|
||||
);
|
||||
const createResults = await Promise.all(createPromises);
|
||||
@@ -8716,6 +8885,8 @@ class ClaudemanApp {
|
||||
claudeModeSelect.onchange = () => {
|
||||
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
|
||||
};
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
// CPU Priority settings
|
||||
const niceSettings = settings.nice || {};
|
||||
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
|
||||
@@ -8832,6 +9003,8 @@ class ClaudemanApp {
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
// CPU Priority settings
|
||||
nice: {
|
||||
enabled: document.getElementById('appSettingsNiceEnabled').checked,
|
||||
@@ -11028,15 +11201,19 @@ class ClaudemanApp {
|
||||
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
|
||||
: '';
|
||||
|
||||
const displayName = agent.description || agent.agentId.substring(0, 7);
|
||||
const teammateInfo = this.getTeammateInfo(agent);
|
||||
const displayName = teammateInfo ? teammateInfo.name : (agent.description || agent.agentId.substring(0, 7));
|
||||
const teammateBadge = this.getTeammateBadgeHtml(agent);
|
||||
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
|
||||
html.push(`
|
||||
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}"
|
||||
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
|
||||
onclick="app.selectSubagent('${agent.agentId}')"
|
||||
ondblclick="app.openSubagentWindow('${agent.agentId}')"
|
||||
title="Double-click to open tracking window">
|
||||
<div class="subagent-header">
|
||||
<span class="subagent-icon">🤖</span>
|
||||
${agentIcon}
|
||||
<span class="subagent-id" title="${this.escapeHtml(agent.description || agent.agentId)}">${this.escapeHtml(displayName.length > 40 ? displayName.substring(0, 40) + '...' : displayName)}</span>
|
||||
${teammateBadge}
|
||||
${modelBadge}
|
||||
<span class="subagent-status ${statusClass}">${agent.status}</span>
|
||||
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${agent.agentId}')" title="Kill agent">✕</button>` : ''}
|
||||
@@ -12287,11 +12464,27 @@ class ClaudemanApp {
|
||||
// Update title/id element with description if available
|
||||
const idEl = win.querySelector('.subagent-window-title .id');
|
||||
if (idEl) {
|
||||
const windowTitle = agent.description || agentId.substring(0, 7);
|
||||
const teammateInfo = this.getTeammateInfo(agent);
|
||||
const windowTitle = teammateInfo ? teammateInfo.name : (agent.description || agentId.substring(0, 7));
|
||||
const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle;
|
||||
idEl.textContent = truncatedTitle;
|
||||
}
|
||||
|
||||
// Add or update teammate badge
|
||||
let tmBadge = win.querySelector('.teammate-badge');
|
||||
const teammateInfo = this.getTeammateInfo(agent);
|
||||
if (teammateInfo && !tmBadge) {
|
||||
const titleContainer = win.querySelector('.subagent-window-title');
|
||||
if (titleContainer) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `teammate-badge teammate-color-${teammateInfo.color}`;
|
||||
badge.title = `Team: ${teammateInfo.teamName}`;
|
||||
badge.textContent = `@${teammateInfo.name}`;
|
||||
const statusEl = titleContainer.querySelector('.status');
|
||||
if (statusEl) statusEl.insertAdjacentElement('beforebegin', badge);
|
||||
}
|
||||
}
|
||||
|
||||
// Update full tooltip
|
||||
const titleContainer = win.querySelector('.subagent-window-title');
|
||||
if (titleContainer) {
|
||||
@@ -12330,6 +12523,120 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Agent Teams ==========
|
||||
|
||||
/** Rebuild the teammate lookup map from all team configs */
|
||||
rebuildTeammateMap() {
|
||||
this.teammateMap.clear();
|
||||
for (const [teamName, team] of this.teams) {
|
||||
for (const member of team.members) {
|
||||
if (member.agentType !== 'team-lead') {
|
||||
// Use name as key prefix for matching subagent descriptions
|
||||
this.teammateMap.set(member.name, {
|
||||
name: member.name,
|
||||
color: member.color || 'blue',
|
||||
teamName,
|
||||
agentId: member.agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a subagent is a teammate and return its info */
|
||||
getTeammateInfo(agent) {
|
||||
if (!agent?.description) return null;
|
||||
// Teammate descriptions start with <teammate-message teammate_id=
|
||||
const match = agent.description.match(/<teammate-message\s+teammate_id="?([^">\s]+)/);
|
||||
if (!match) return null;
|
||||
const teammateId = match[1];
|
||||
// Extract name from teammate_id (format: name@teamName)
|
||||
const name = teammateId.split('@')[0];
|
||||
return this.teammateMap.get(name) || { name, color: 'blue', teamName: 'unknown' };
|
||||
}
|
||||
|
||||
/** Get teammate badge HTML for a subagent */
|
||||
getTeammateBadgeHtml(agent) {
|
||||
const info = this.getTeammateInfo(agent);
|
||||
if (!info) return '';
|
||||
return `<span class="teammate-badge teammate-color-${info.color}" title="Team: ${this.escapeHtml(info.teamName)}">@${this.escapeHtml(info.name)}</span>`;
|
||||
}
|
||||
|
||||
/** Render the team tasks panel */
|
||||
renderTeamTasksPanel() {
|
||||
const panel = document.getElementById('teamTasksPanel');
|
||||
if (!panel) return;
|
||||
|
||||
// Find team for active session
|
||||
let activeTeam = null;
|
||||
let activeTeamName = null;
|
||||
if (this.activeSessionId) {
|
||||
for (const [name, team] of this.teams) {
|
||||
if (team.leadSessionId === this.activeSessionId) {
|
||||
activeTeam = team;
|
||||
activeTeamName = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeTeam) {
|
||||
panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
panel.style.display = '';
|
||||
const tasks = this.teamTasks.get(activeTeamName) || [];
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const total = tasks.length;
|
||||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
const headerEl = panel.querySelector('.team-tasks-header-text');
|
||||
if (headerEl) {
|
||||
const teammateCount = activeTeam.members.filter(m => m.agentType !== 'team-lead').length;
|
||||
headerEl.textContent = `Team Tasks (${teammateCount} teammates)`;
|
||||
}
|
||||
|
||||
const progressEl = panel.querySelector('.team-tasks-progress-fill');
|
||||
if (progressEl) {
|
||||
progressEl.style.width = `${pct}%`;
|
||||
}
|
||||
|
||||
const progressText = panel.querySelector('.team-tasks-progress-text');
|
||||
if (progressText) {
|
||||
progressText.textContent = `${completed}/${total}`;
|
||||
}
|
||||
|
||||
const listEl = panel.querySelector('.team-tasks-list');
|
||||
if (!listEl) return;
|
||||
|
||||
if (tasks.length === 0) {
|
||||
listEl.innerHTML = '<div class="team-task-empty">No tasks yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = tasks.map(task => {
|
||||
const statusIcon = task.status === 'completed' ? '✓' : task.status === 'in_progress' ? '◉' : '○';
|
||||
const statusClass = task.status.replace('_', '-');
|
||||
const ownerBadge = task.owner
|
||||
? `<span class="team-task-owner teammate-color-${this.getTeammateColor(task.owner)}">${this.escapeHtml(task.owner)}</span>`
|
||||
: '';
|
||||
return `<div class="team-task-item ${statusClass}">
|
||||
<span class="team-task-status">${statusIcon}</span>
|
||||
<span class="team-task-subject">${this.escapeHtml(task.subject)}</span>
|
||||
${ownerBadge}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
listEl.innerHTML = html;
|
||||
}
|
||||
|
||||
/** Get teammate color by name */
|
||||
getTeammateColor(name) {
|
||||
const info = this.teammateMap.get(name);
|
||||
return info?.color || 'blue';
|
||||
}
|
||||
|
||||
// ========== Project Insights Panel (Bash Tools with Clickable File Paths) ==========
|
||||
|
||||
/**
|
||||
@@ -13112,6 +13419,14 @@ class ClaudemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cap open popups at 20 — close oldest when at limit
|
||||
const MAX_IMAGE_POPUPS = 20;
|
||||
if (this.imagePopups.size >= MAX_IMAGE_POPUPS) {
|
||||
// Map iteration order is insertion order, so first key is oldest
|
||||
const oldestId = this.imagePopups.keys().next().value;
|
||||
if (oldestId) this.closeImagePopup(oldestId);
|
||||
}
|
||||
|
||||
// Calculate position (cascade from center, with offset for multiple popups)
|
||||
const windowCount = this.imagePopups.size;
|
||||
const centerX = (window.innerWidth - 600) / 2;
|
||||
@@ -13288,6 +13603,50 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Case Settings ==========
|
||||
|
||||
toggleCaseSettings() {
|
||||
const popover = document.getElementById('caseSettingsPopover');
|
||||
if (popover.classList.contains('hidden')) {
|
||||
// Load settings for current case
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
|
||||
popover.classList.remove('hidden');
|
||||
|
||||
// Close on outside click (one-shot listener)
|
||||
const closeHandler = (e) => {
|
||||
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings')) {
|
||||
popover.classList.add('hidden');
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
// Defer to avoid catching the current click
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||
} else {
|
||||
popover.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
getCaseSettings(caseName) {
|
||||
try {
|
||||
const stored = localStorage.getItem('caseSettings_' + caseName);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch { /* ignore */ }
|
||||
return { agentTeams: false };
|
||||
}
|
||||
|
||||
saveCaseSettings(caseName, settings) {
|
||||
localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
onCaseSettingChanged() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
settings.agentTeams = document.getElementById('caseAgentTeams').checked;
|
||||
this.saveCaseSettings(caseName, settings);
|
||||
}
|
||||
|
||||
// ========== Create Case Modal ==========
|
||||
|
||||
showCreateCaseModal() {
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
</div>
|
||||
<div class="header-font-controls">
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
|
||||
<span class="font-size-display" id="fontSizeDisplay" aria-label="Current font size">14</span>
|
||||
@@ -285,6 +289,14 @@
|
||||
<option value="testcase">testcase</option>
|
||||
</select>
|
||||
<button class="btn-case-add" onclick="app.showCreateCaseModal()" title="Create new case">+</button>
|
||||
<button class="btn-case-settings" onclick="app.toggleCaseSettings()" title="Case settings">⚙</button>
|
||||
<div class="case-settings-popover hidden" id="caseSettingsPopover">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="caseAgentTeams" onchange="app.onCaseSettingChanged()">
|
||||
<span>Agent Teams</span>
|
||||
</label>
|
||||
<span class="form-hint">Enable experimental Agent Teams for new sessions in this case</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile-only case button -->
|
||||
<button class="btn-toolbar btn-case-mobile" onclick="app.showMobileCasePicker()" title="Select case">
|
||||
@@ -413,6 +425,20 @@
|
||||
</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>
|
||||
</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>
|
||||
@@ -837,6 +863,16 @@
|
||||
</label>
|
||||
<span class="form-hint">Auto-enable for new sessions (otherwise auto-enables on Ralph pattern detection)</span>
|
||||
</div>
|
||||
<!-- Claude Permissions Section -->
|
||||
<div class="form-section-header">Claude Permissions</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>Agent Teams</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsAgentTeams">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
|
||||
</div>
|
||||
<!-- Nice Priority Section -->
|
||||
<div class="form-section-header">Nice Priority</div>
|
||||
<div class="form-row form-row-switch">
|
||||
|
||||
+278
-2
@@ -208,8 +208,16 @@ body {
|
||||
}
|
||||
|
||||
.session-tab:hover {
|
||||
background: var(--bg-hover);
|
||||
background: rgba(34, 197, 94, 0.06);
|
||||
color: var(--text);
|
||||
box-shadow: 0 0 8px 2px rgba(34, 197, 94, 0.2), inset 0 0 6px 1px rgba(34, 197, 94, 0.08);
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.session-tab:active {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
box-shadow: 0 0 16px 5px rgba(34, 197, 94, 0.5), 0 0 30px 10px rgba(34, 197, 94, 0.2), inset 0 0 10px 2px rgba(34, 197, 94, 0.2);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.session-tab.active {
|
||||
@@ -218,6 +226,18 @@ body {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Tab switch feedback: bright green glow on the newly-active tab */
|
||||
.session-tab.tab-glow {
|
||||
animation: tab-glow 1s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes tab-glow {
|
||||
0% { box-shadow: 0 0 0px 0px rgba(34, 197, 94, 0); background: transparent; }
|
||||
10% { box-shadow: 0 0 18px 6px rgba(34, 197, 94, 0.7), 0 0 40px 12px rgba(34, 197, 94, 0.3), inset 0 0 12px 3px rgba(34, 197, 94, 0.25); background: rgba(34, 197, 94, 0.12); }
|
||||
30% { box-shadow: 0 0 14px 5px rgba(34, 197, 94, 0.5), 0 0 30px 10px rgba(34, 197, 94, 0.2), inset 0 0 10px 2px rgba(34, 197, 94, 0.15); background: rgba(34, 197, 94, 0.08); }
|
||||
100% { box-shadow: 0 0 0px 0px rgba(34, 197, 94, 0); background: transparent; }
|
||||
}
|
||||
|
||||
.session-tab .tab-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -345,6 +365,44 @@ body {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Connection Indicator */
|
||||
.connection-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-dot.offline {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 6px var(--red);
|
||||
}
|
||||
|
||||
.connection-dot.reconnecting {
|
||||
background: var(--yellow);
|
||||
animation: connection-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.connection-dot.draining {
|
||||
background: var(--green);
|
||||
animation: connection-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes connection-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.header-tokens {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -1616,6 +1674,23 @@ body {
|
||||
will-change: contents;
|
||||
}
|
||||
|
||||
/* Tab switch feedback: green glow emerging from black */
|
||||
.tab-switch-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
background: radial-gradient(ellipse at center, rgba(34, 197, 94, 0.12) 0%, transparent 70%);
|
||||
box-shadow: inset 0 0 150px 60px rgba(34, 197, 94, 0.35);
|
||||
animation: tab-switch-glow 0.7s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes tab-switch-glow {
|
||||
0% { opacity: 0; }
|
||||
15% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.terminal-container .xterm {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
@@ -1931,6 +2006,7 @@ body {
|
||||
.case-select-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
@@ -1968,7 +2044,7 @@ body {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
border-radius: 0;
|
||||
color: var(--green);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
@@ -1982,6 +2058,51 @@ body {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.btn-case-settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-case-settings:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.case-settings-popover {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 4px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
width: 220px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.case-settings-popover.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.case-settings-popover .form-hint {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.toolbar-input {
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
@@ -4592,6 +4713,10 @@ kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar-left .toolbar-group:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
@@ -5360,6 +5485,153 @@ 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;
|
||||
bottom: 50px;
|
||||
right: 10px;
|
||||
width: 320px;
|
||||
max-height: 300px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 900;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.team-tasks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.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;
|
||||
max-height: 250px;
|
||||
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 {
|
||||
@@ -8481,6 +8753,10 @@ kbd {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-case-settings {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* When keyboard is visible, also move accessory bar up */
|
||||
.keyboard-visible .keyboard-accessory-bar.visible {
|
||||
/* Position is handled by JS transform along with toolbar */
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@ export const CreateSessionSchema = z.object({
|
||||
workingDir: z.string().optional(),
|
||||
mode: z.enum(['claude', 'shell']).optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
envOverrides: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -76,7 +77,7 @@ export const QuickStartSchema = z.object({
|
||||
* Receives Claude Code hook events.
|
||||
*/
|
||||
export const HookEventSchema = z.object({
|
||||
event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop']),
|
||||
event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop', 'teammate_idle', 'task_completed']),
|
||||
sessionId: z.string().min(1),
|
||||
data: z.unknown().optional(),
|
||||
});
|
||||
|
||||
+87
-1
@@ -26,7 +26,7 @@ import { ScreenManager } from '../screen-manager.js';
|
||||
import { getStore } from '../state-store.js';
|
||||
import { generateClaudeMd } from '../templates/claude-md.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { writeHooksConfig } from '../hooks-config.js';
|
||||
import { writeHooksConfig, updateCaseEnvVars } from '../hooks-config.js';
|
||||
import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage, type SubagentToolResult } from '../subagent-watcher.js';
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||
@@ -34,6 +34,7 @@ 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);
|
||||
@@ -51,6 +52,9 @@ import {
|
||||
type PersistedRespawnConfig,
|
||||
type NiceConfig,
|
||||
type ImageDetectedEvent,
|
||||
type TeamConfig,
|
||||
type TeamTask,
|
||||
type InboxMessage,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
} from '../types.js';
|
||||
import {
|
||||
@@ -370,6 +374,7 @@ export class WebServer extends EventEmitter {
|
||||
detected: (event: ImageDetectedEvent) => void;
|
||||
error: (error: Error, sessionId?: string) => void;
|
||||
} | null = null;
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
|
||||
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
|
||||
super();
|
||||
@@ -484,6 +489,26 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private setupTeamWatcherListeners(): void {
|
||||
this.teamWatcher.on('teamCreated', (team: TeamConfig) => {
|
||||
this.broadcast('team:created', team);
|
||||
});
|
||||
this.teamWatcher.on('teamUpdated', (team: TeamConfig) => {
|
||||
this.broadcast('team:updated', team);
|
||||
});
|
||||
this.teamWatcher.on('teamRemoved', (team: TeamConfig) => {
|
||||
this.broadcast('team:removed', team);
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
private async setupRoutes(): Promise<void> {
|
||||
// Serve static files
|
||||
await this.app.register(fastifyStatic, {
|
||||
@@ -655,6 +680,12 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
const body = result.data;
|
||||
const workingDir = body.workingDir || process.cwd();
|
||||
|
||||
// Write env overrides to .claude/settings.local.json if provided
|
||||
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
|
||||
updateCaseEnvVars(workingDir, body.envOverrides);
|
||||
}
|
||||
|
||||
const globalNice = this.getGlobalNiceConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
@@ -3295,6 +3326,49 @@ 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 };
|
||||
});
|
||||
|
||||
// ========== Hook Events ==========
|
||||
|
||||
this.app.post('/api/hook-event', async (req) => {
|
||||
@@ -3911,6 +3985,9 @@ 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);
|
||||
|
||||
@@ -4717,6 +4794,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5012,6 +5094,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Stop image watcher
|
||||
imageWatcher.stop();
|
||||
|
||||
// Stop team watcher
|
||||
this.teamWatcher.removeAllListeners();
|
||||
this.teamWatcher.stop();
|
||||
|
||||
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
|
||||
fileStreamManager.destroy();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user