chore: bump version to 0.1479

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-12 08:34:51 +01:00
co-authored by Claude Opus 4.6
parent df84804189
commit 3145b3f9a3
19 changed files with 2709 additions and 24 deletions
+14 -1
View File
@@ -149,12 +149,25 @@ export class AiIdleChecker extends AiCheckerBase<
protected readonly logPrefix = '[AiIdleChecker]';
protected readonly checkDescription = 'AI idle check';
/** Number of active teammates (injected by respawn controller) */
private teammateCount: number = 0;
constructor(sessionId: string, config: Partial<AiIdleCheckConfig> = {}) {
super(sessionId, DEFAULT_AI_CHECK_CONFIG, config);
}
/** Set active teammate count for prompt context injection */
setTeammateCount(count: number): void {
this.teammateCount = count;
}
protected buildPrompt(terminalBuffer: string): string {
return AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer);
let prompt = AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer);
if (this.teammateCount > 0) {
const teamContext = `\n\n## Agent Teams Context\n${this.teammateCount} agent teammate(s) are currently working on tasks for this session.\nIf teammates are active, answer WORKING — the lead is waiting for their results.\n`;
prompt = prompt.replace('## Terminal Output to Analyze', teamContext + '## Terminal Output to Analyze');
}
return prompt;
}
protected parseVerdict(output: string): { verdict: AiCheckVerdict; reasoning: string } | null {
+44
View File
@@ -52,10 +52,54 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: 10000 }],
},
],
TeammateIdle: [
{
hooks: [{ type: 'command', command: curlCmd('teammate_idle'), timeout: 10000 }],
},
],
TaskCompleted: [
{
hooks: [{ type: 'command', command: curlCmd('task_completed'), timeout: 10000 }],
},
],
},
};
}
/**
* Updates env vars in .claude/settings.local.json for the given case path.
* Merges with existing env field; removes vars set to empty string.
*/
export function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): void {
const claudeDir = join(casePath, '.claude');
if (!existsSync(claudeDir)) {
mkdirSync(claudeDir, { recursive: true });
}
const settingsPath = join(claudeDir, 'settings.local.json');
let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
} catch {
existing = {};
}
}
const currentEnv = (existing.env as Record<string, string>) || {};
for (const [key, value] of Object.entries(envVars)) {
if (value) {
currentEnv[key] = value;
} else {
delete currentEnv[key];
}
}
existing.env = currentEnv;
writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n');
}
/**
* Writes hooks config to .claude/settings.local.json in the given case path.
* Merges with existing file content, only touching the `hooks` key.
+31
View File
@@ -43,6 +43,12 @@ const POLL_INTERVAL_MS = 100;
/** Debounce delay for rapid image creation (ms) */
const DEBOUNCE_DELAY_MS = 200;
/** Max images emitted per session within the burst window before throttling */
const BURST_LIMIT = 20;
/** Time window for burst detection (ms) — resets after this period of quiet */
const BURST_WINDOW_MS = 10_000;
// ========== ImageWatcher Class ==========
/**
@@ -73,6 +79,9 @@ export class ImageWatcher extends EventEmitter {
/** Track which session owns each debounce timer (for cleanup) */
private timerToSession = new Map<string, string>();
/** Per-session burst tracking: sessionId -> { count, windowStart } */
private burstTrackers = new Map<string, { count: number; windowStart: number }>();
/** Whether the watcher is currently running */
private _isRunning = false;
@@ -121,6 +130,7 @@ export class ImageWatcher extends EventEmitter {
}
this.debounceTimers.clear();
this.timerToSession.clear();
this.burstTrackers.clear();
}
/**
@@ -219,6 +229,7 @@ export class ImageWatcher extends EventEmitter {
}
this.timerToSession.delete(filePath);
}
this.burstTrackers.delete(sessionId);
}
/**
@@ -242,6 +253,23 @@ export class ImageWatcher extends EventEmitter {
return;
}
// Burst limit: skip if too many images detected for this session in a short window
const now = Date.now();
let burst = this.burstTrackers.get(sessionId);
if (burst) {
if (now - burst.windowStart > BURST_WINDOW_MS) {
// Window expired, reset
burst = { count: 0, windowStart: now };
this.burstTrackers.set(sessionId, burst);
}
if (burst.count >= BURST_LIMIT) {
return; // Throttled — too many images in this window
}
} else {
burst = { count: 0, windowStart: now };
this.burstTrackers.set(sessionId, burst);
}
// Debounce rapid file creation (e.g., multiple screenshots quickly)
const existingTimer = this.debounceTimers.get(filePath);
if (existingTimer) {
@@ -252,6 +280,9 @@ export class ImageWatcher extends EventEmitter {
this.debounceTimers.delete(filePath);
this.timerToSession.delete(filePath);
this.emitImageDetected(sessionId, filePath);
// Increment burst count on actual emission (not on detection)
const b = this.burstTrackers.get(sessionId);
if (b) b.count++;
}, DEBOUNCE_DELAY_MS);
this.debounceTimers.set(filePath, timer);
+25
View File
@@ -57,6 +57,7 @@ import type {
CycleOutcome,
HealthStatus,
} from './types.js';
import type { TeamWatcher } from './team-watcher.js';
// ========== Constants ==========
@@ -638,6 +639,9 @@ export class RespawnController extends EventEmitter {
/** The session being controlled */
private session: Session;
/** Optional team watcher for team-aware idle detection */
private teamWatcher: TeamWatcher | null = null;
/** Current configuration */
private config: RespawnConfig;
@@ -1231,6 +1235,10 @@ export class RespawnController extends EventEmitter {
*
* @fires stateChanged - Transitions to 'watching'
*/
setTeamWatcher(watcher: TeamWatcher | null): void {
this.teamWatcher = watcher;
}
start(): void {
if (!this.config.enabled) {
this.log('Respawn is disabled');
@@ -2183,6 +2191,12 @@ export class RespawnController extends EventEmitter {
// Get the terminal buffer for analysis
const buffer = this.terminalBuffer.value;
// Inject teammate context for team-aware AI check
if (this.teamWatcher) {
const count = this.teamWatcher.getActiveTeammateCount(this.session.id);
this.aiChecker.setTeammateCount(count);
}
this.aiChecker.check(buffer).then((result) => {
// If state changed while checking (e.g., cancelled), ignore result
if (this._state !== 'ai_checking') {
@@ -2736,6 +2750,17 @@ export class RespawnController extends EventEmitter {
return;
}
// Team-awareness: don't respawn if teammates are active
if (this.teamWatcher?.hasActiveTeammates(this.session.id)) {
this.log(`Idle confirmation rejected - active teammates working (reason was: ${reason})`);
this.logAction('detection', 'Rejected: Active teammates');
this.emit('respawnBlocked', { reason: 'active_teammates', details: 'Team has active teammates' });
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
return;
}
this.log(`Idle confirmed via: ${reason}`);
const status = this.getDetectionStatus();
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
+313
View File
@@ -0,0 +1,313 @@
/**
* @fileoverview Agent Teams Watcher
*
* Polls ~/.claude/teams/ and ~/.claude/tasks/ for agent team activity.
* Matches teams to Claudeman sessions via leadSessionId and emits
* events for UI updates and team-aware idle detection.
*
* @module team-watcher
*/
import { EventEmitter } from 'node:events';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { TeamConfig, TeamTask, InboxMessage } from './types.js';
import { LRUMap } from './utils/lru-map.js';
// ========== Constants ==========
const POLL_INTERVAL_MS = 3000;
const MAX_CACHED_TEAMS = 50;
const MAX_CACHED_TASKS = 200;
// ========== TeamWatcher Class ==========
export class TeamWatcher extends EventEmitter {
private teamsDir: string;
private tasksDir: string;
private pollTimer: NodeJS.Timeout | null = null;
private teams: LRUMap<string, TeamConfig> = new LRUMap({ maxSize: MAX_CACHED_TEAMS });
private teamTasks: LRUMap<string, TeamTask[]> = new LRUMap({ maxSize: MAX_CACHED_TASKS });
private inboxCache: LRUMap<string, InboxMessage[]> = new LRUMap({ maxSize: MAX_CACHED_TASKS });
// Track config mtimes to avoid re-reading unchanged files
private configMtimes: Map<string, number> = new Map();
private taskMtimes: Map<string, number> = new Map();
private inboxMtimes: Map<string, number> = new Map();
constructor(teamsDir?: string, tasksDir?: string) {
super();
const claudeHome = join(homedir(), '.claude');
this.teamsDir = teamsDir || join(claudeHome, 'teams');
this.tasksDir = tasksDir || join(claudeHome, 'tasks');
}
start(): void {
if (this.pollTimer) return;
this.poll();
this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
}
stop(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
this.teams.clear();
this.teamTasks.clear();
this.inboxCache.clear();
this.configMtimes.clear();
this.taskMtimes.clear();
this.inboxMtimes.clear();
}
/** Get all discovered teams */
getTeams(): TeamConfig[] {
return Array.from(this.teams.values());
}
/** Get team associated with a Claudeman session (matched by leadSessionId) */
getTeamForSession(sessionId: string): TeamConfig | undefined {
for (const team of this.teams.values()) {
if (team.leadSessionId === sessionId) {
return team;
}
}
return undefined;
}
/** Get tasks for a team (excluding internal tasks) */
getTeamTasks(teamName: string): TeamTask[] {
const tasks = this.teamTasks.get(teamName);
if (!tasks) return [];
return tasks.filter(t => !t.metadata?._internal);
}
/** Count active (non-completed) tasks for a team */
getActiveTaskCount(teamName: string): number {
const tasks = this.getTeamTasks(teamName);
return tasks.filter(t => t.status !== 'completed').length;
}
/** Get inbox messages for a team member (or all members) */
getInboxMessages(teamName: string, member?: string): InboxMessage[] {
if (member) {
const key = `${teamName}/${member}`;
return this.inboxCache.get(key) || [];
}
// Return all messages for team
const messages: InboxMessage[] = [];
for (const [key, msgs] of this.inboxCache.entries()) {
if (key.startsWith(`${teamName}/`)) {
messages.push(...msgs);
}
}
return messages.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
}
/** Check if a session has active teammates (for idle detection) */
hasActiveTeammates(sessionId: string): boolean {
const team = this.getTeamForSession(sessionId);
if (!team) return false;
// Check if any non-lead members exist (they are active by definition while present)
const teammates = team.members.filter(m => m.agentType !== 'team-lead');
if (teammates.length === 0) return false;
// Check if team has active (non-completed) tasks
const activeTasks = this.getActiveTaskCount(team.name);
return activeTasks > 0;
}
/** Get count of active teammates for a session */
getActiveTeammateCount(sessionId: string): number {
const team = this.getTeamForSession(sessionId);
if (!team) return 0;
return team.members.filter(m => m.agentType !== 'team-lead').length;
}
// ========== Private Methods ==========
private poll(): void {
try {
this.pollTeams();
this.pollTasks();
this.pollInboxes();
} catch (err) {
// Don't crash on polling errors — filesystem may be temporarily unavailable
}
}
private pollTeams(): void {
if (!existsSync(this.teamsDir)) return;
let entries: string[];
try {
entries = readdirSync(this.teamsDir);
} catch {
return;
}
const currentTeamNames = new Set<string>();
for (const entry of entries) {
const configPath = join(this.teamsDir, entry, 'config.json');
if (!existsSync(configPath)) continue;
currentTeamNames.add(entry);
// Check mtime to skip unchanged configs
try {
const mtime = statSync(configPath).mtimeMs;
if (this.configMtimes.get(entry) === mtime) continue;
this.configMtimes.set(entry, mtime);
} catch {
continue;
}
// Skip if locked
if (this.isLocked(join(this.teamsDir, entry, 'config.json'))) continue;
const config = this.readJson<TeamConfig>(configPath);
if (!config || !config.name) continue;
const existing = this.teams.get(entry);
this.teams.set(entry, config);
if (existing) {
this.emit('teamUpdated', config);
} else {
this.emit('teamCreated', config);
}
}
// Detect removed teams
for (const name of Array.from(this.teams.keys())) {
if (!currentTeamNames.has(name)) {
const removed = this.teams.get(name);
this.teams.delete(name);
this.configMtimes.delete(name);
if (removed) {
this.emit('teamRemoved', removed);
}
}
}
}
private pollTasks(): void {
if (!existsSync(this.tasksDir)) return;
let teamDirs: string[];
try {
teamDirs = readdirSync(this.tasksDir);
} catch {
return;
}
for (const teamName of teamDirs) {
const teamTaskDir = join(this.tasksDir, teamName);
let taskFiles: string[];
try {
taskFiles = readdirSync(teamTaskDir).filter(f => f.endsWith('.json') && f !== '.lock');
} catch {
continue;
}
// Check combined mtime for all task files
const mtimeKey = teamName;
let combinedMtime = 0;
for (const f of taskFiles) {
try {
combinedMtime += statSync(join(teamTaskDir, f)).mtimeMs;
} catch {
// File may have been deleted between readdir and stat
}
}
if (this.taskMtimes.get(mtimeKey) === combinedMtime) continue;
this.taskMtimes.set(mtimeKey, combinedMtime);
// Skip if locked
if (this.isLocked(join(teamTaskDir, '.lock'))) continue;
const tasks: TeamTask[] = [];
for (const f of taskFiles) {
const task = this.readJson<TeamTask>(join(teamTaskDir, f));
if (task && task.id) {
tasks.push(task);
}
}
this.teamTasks.set(teamName, tasks);
this.emit('taskUpdated', { teamName, tasks });
}
}
private pollInboxes(): void {
// Inbox files live under ~/.claude/teams/{name}/inboxes/
for (const [teamName] of this.teams.entries()) {
const inboxDir = join(this.teamsDir, teamName, 'inboxes');
if (!existsSync(inboxDir)) continue;
let inboxFiles: string[];
try {
inboxFiles = readdirSync(inboxDir).filter(f => f.endsWith('.json'));
} catch {
continue;
}
for (const f of inboxFiles) {
const filePath = join(inboxDir, f);
const memberName = f.replace('.json', '');
const cacheKey = `${teamName}/${memberName}`;
// Check mtime
try {
const mtime = statSync(filePath).mtimeMs;
if (this.inboxMtimes.get(cacheKey) === mtime) continue;
this.inboxMtimes.set(cacheKey, mtime);
} catch {
continue;
}
// Skip if locked
if (this.isLocked(filePath)) continue;
const messages = this.readJson<InboxMessage[]>(filePath);
if (!Array.isArray(messages)) continue;
const previous = this.inboxCache.get(cacheKey);
this.inboxCache.set(cacheKey, messages);
// Emit new messages (ones not in previous cache)
const prevCount = previous?.length || 0;
if (messages.length > prevCount) {
for (let i = prevCount; i < messages.length; i++) {
this.emit('inboxMessage', { teamName, member: memberName, message: messages[i] });
}
}
}
}
}
/** Check for directory-based lock (mkdir atomic locking) */
private isLocked(path: string): boolean {
const lockDir = `${path}.lock`;
try {
return existsSync(lockDir) && statSync(lockDir).isDirectory();
} catch {
return false;
}
}
private readJson<T>(filePath: string): T | null {
try {
const content = readFileSync(filePath, 'utf-8');
return JSON.parse(content) as T;
} catch {
return null;
}
}
}
+67 -1
View File
@@ -746,7 +746,7 @@ export interface QuickRunRequest {
/**
* Hook event types triggered by Claude Code's hooks system
*/
export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog' | 'stop';
export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog' | 'stop' | 'teammate_idle' | 'task_completed';
/**
* Request body for the hook-event API endpoint
@@ -1514,6 +1514,72 @@ export interface ImageDetectedEvent {
size: number;
}
// ========== Agent Teams Types ==========
/**
* Team configuration matching ~/.claude/teams/{name}/config.json
*/
export interface TeamConfig {
name: string;
description?: string;
createdAt: number;
leadAgentId: string;
leadSessionId: string; // Maps to Claudeman session ID
members: TeamMember[];
}
/**
* A member of an agent team (lead or teammate)
*/
export interface TeamMember {
agentId: string; // Format: {name}@{teamName}
name: string;
agentType: 'team-lead' | 'general-purpose';
model?: string;
prompt?: string; // Only present for teammates
color?: string; // blue, green, yellow
backendType?: string; // "in-process"
joinedAt: number;
tmuxPaneId?: string;
cwd?: string;
}
/**
* Team task matching ~/.claude/tasks/{team}/{N}.json
*/
export interface TeamTask {
id: string;
subject: string;
description: string;
activeForm?: string;
status: 'pending' | 'in_progress' | 'completed';
owner?: string;
blocks: string[];
blockedBy: string[];
metadata?: { _internal?: boolean };
}
/**
* Inbox message from ~/.claude/teams/{name}/inboxes/{member}.json
*/
export interface InboxMessage {
from: string;
text: string; // Double-encoded JSON string
timestamp: string;
read: boolean;
}
/**
* Parsed content from an inbox message's text field
*/
export interface InboxMessageContent {
type: 'task_assignment' | 'shutdown_request' | 'shutdown_response' | 'message' | 'broadcast';
taskId?: string;
subject?: string;
assignedBy?: string;
timestamp?: string;
}
// ========== Plan Orchestrator Re-exports ==========
export type { PlanItem } from './plan-orchestrator.js';
+374 -15
View File
@@ -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">&#x2715;</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() {
+36
View File
@@ -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">&#x2699;</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
View File
@@ -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
View File
@@ -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
View File
@@ -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();