chore: bump version to 0.1488

This commit is contained in:
arkon
2026-02-12 14:41:27 +01:00
parent 2b685cea65
commit e44f827473
16 changed files with 295 additions and 865 deletions
+17 -28
View File
@@ -14,10 +14,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## CRITICAL: Session Safety
**You may be running inside a Claudeman-managed tmux/screen session.** Before killing ANY tmux, screen, or Claude process:
**You may be running inside a Claudeman-managed tmux session.** Before killing ANY tmux or Claude process:
1. Check: `echo $CLAUDEMAN_SCREEN` - if `1`, you're in a managed session
2. **NEVER** run `tmux kill-session`, `screen -X quit`, `pkill tmux`, `pkill screen`, or `pkill claude` without confirming
1. Check: `echo $CLAUDEMAN_TMUX` - if `1`, you're in a managed session
2. **NEVER** run `tmux kill-session`, `pkill tmux`, or `pkill claude` without confirming
3. Use the web UI or `./scripts/tmux-manager.sh` instead of direct kill commands
## COM Shorthand (Deployment)
@@ -26,7 +26,7 @@ When user says "COM":
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
**Version**: 0.1487 (must match `package.json` for npm publish)
**Version**: 0.1488 (must match `package.json` for npm publish)
## Project Overview
@@ -36,7 +36,7 @@ Claudeman is a Claude Code session manager with web interface and autonomous Ral
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`. Note: `src/tui` is excluded from compilation (legacy/deprecated code path).
**Requirements**: Node.js 18+, Claude CLI, tmux (preferred) or GNU Screen (deprecated fallback via `CLAUDEMAN_MUX=screen`)
**Requirements**: Node.js 18+, Claude CLI, tmux
## Commands
@@ -72,8 +72,7 @@ journalctl --user -u claudeman-web -f
- **`npm run dev` is NOT the web server** — it shows CLI help. Use `npx tsx src/index.ts web`
- **Single-line prompts only** — `writeViaScreen()` sends text and Enter separately; multi-line breaks Ink
- **Test screens need 'test' in name** — The cleanup system only kills screens containing 'test'
- **Don't kill screens blindly** — Check `$CLAUDEMAN_SCREEN` first; you might be inside one
- **Don't kill tmux sessions blindly** — Check `$CLAUDEMAN_TMUX` first; you might be inside one
- **Port 3000 during E2E** — Tests use ports 3183-3193; don't run dev server on 3000 while testing
## Import Conventions
@@ -90,9 +89,9 @@ journalctl --user -u claudeman-web -f
|------|---------|
| `src/session.ts` | PTY wrapper: `runPrompt()`, `startInteractive()`, `startShell()` |
| `src/mux-interface.ts` | `TerminalMultiplexer` interface + `MuxSession` type |
| `src/mux-factory.ts` | Auto-detect tmux/screen, create multiplexer (`CLAUDEMAN_MUX` override) |
| `src/tmux-manager.ts` | tmux session management (preferred backend) |
| `src/screen-manager.ts` | GNU screen persistence, ghost discovery (deprecated fallback) |
| `src/mux-factory.ts` | Create tmux multiplexer (`CLAUDEMAN_MUX` override for legacy screen) |
| `src/tmux-manager.ts` | tmux session management |
| `src/screen-manager.ts` | GNU screen fallback (deprecated) |
| `src/session-manager.ts` | Session lifecycle, cleanup |
| `src/state-store.ts` | State persistence to `~/.claudeman/state.json` |
| `src/respawn-controller.ts` | State machine for autonomous cycling |
@@ -155,9 +154,9 @@ journalctl --user -u claudeman-web -f
### Key Patterns
**Input to sessions**: Use `session.writeViaScreen()` for programmatic input (respawn, auto-compact). With tmux, uses `send-keys -l` (literal text) + `send-keys Enter` — single command, no delay. With screen (deprecated), text and Enter sent as separate `screen -X stuff` commands with 100ms delay. All prompts must be single-line.
**Input to sessions**: Use `session.writeViaScreen()` for programmatic input (respawn, auto-compact). Uses tmux `send-keys -l` (literal text) + `send-keys Enter`. All prompts must be single-line.
**Terminal multiplexer abstraction**: `TerminalMultiplexer` interface (`src/mux-interface.ts`) abstracts tmux vs screen. `createMultiplexer()` from `src/mux-factory.ts` auto-detects tmux (preferred) or falls back to screen. Set `CLAUDEMAN_MUX=screen` env var to force screen backend.
**Terminal multiplexer**: `TerminalMultiplexer` interface (`src/mux-interface.ts`) abstracts the backend. `createMultiplexer()` from `src/mux-factory.ts` creates the tmux backend. Legacy screen fallback exists via `CLAUDEMAN_MUX=screen` but is deprecated.
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
@@ -182,7 +181,6 @@ journalctl --user -u claudeman-web -f
|------|---------|
| `~/.claudeman/state.json` | Sessions, settings, tokens, respawn config |
| `~/.claudeman/mux-sessions.json` | Tmux session metadata for recovery |
| `~/.claudeman/screens.json` | Legacy screen metadata (auto-migrated to mux-sessions.json) |
| `~/.claudeman/settings.json` | User preferences |
## Default Settings
@@ -193,20 +191,13 @@ UI defaults are set in `src/web/public/app.js` using `??` fallbacks. To change d
## Testing
**Port allocation**: E2E tests use centralized ports in `test/e2e/e2e.config.ts` (3183-3193). Unit/integration tests pick unique ports manually (team tests: 3150-3151). Search `const PORT =` or `TEST_PORT` in test files to find used ports before adding new tests.
**Ports**: E2E uses 3183-3193 (see `test/e2e/e2e.config.ts`). Unit tests pick unique ports manually. Search `const PORT =` before adding new tests.
**E2E tests**: Use Playwright. Run `npx playwright install chromium` first. See `test/e2e/fixtures/` for helpers. E2E config (`test/e2e/e2e.config.ts`) provides ports (3183-3193), timeouts, and helpers.
**Config**: Vitest with `globals: true`, `fileParallelism: false`. Unit timeout 30s, E2E timeout 90s. E2E requires `npx playwright install chromium`.
**Test config**: Vitest runs with `globals: true` (no imports needed for `describe`/`it`/`expect`/`vi`) and `fileParallelism: false` (files run sequentially to respect screen limits). Unit test timeout is 30s, teardown timeout is 60s. E2E tests have longer timeouts defined in `test/e2e/e2e.config.ts` (90s test, 30s session creation). Mock helpers in `test/setup.ts` auto-run before all tests.
**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions at load time and never kills them. Only sessions registered via `registerTestTmuxSession()` get cleaned up.
**Test safety**: `test/setup.ts` provides:
- Screen concurrency limiter (max 10)
- Pre-existing screen protection (never kills screens present before tests)
- Tracked resource cleanup (only kills screens/processes tests register)
- Safe to run from within Claudeman-managed sessions
- Exported helpers: `acquireScreenSlot()`, `releaseScreenSlot()`, `registerTestScreen()`, `unregisterTestScreen()`
Respawn tests use MockSession to avoid spawning real Claude processes. See `test/respawn-test-utils.ts` for MockSession, MockAiIdleChecker, MockAiPlanChecker, state trackers, and terminal output generators.
**Respawn tests**: Use MockSession from `test/respawn-test-utils.ts` to avoid spawning real Claude processes.
## Debugging
@@ -231,7 +222,7 @@ curl localhost:3000/api/sessions/:id/run-summary | jq # Session timeline
| SSE not connecting | Browser console for errors | Check CORS, ensure server running |
| Respawn not triggering | Session settings → Respawn enabled? | Enable respawn, check idle timeout config |
| Terminal blank on tab switch | Network tab for `/api/sessions/:id/buffer` | Check session exists, restart server |
| Tests failing on session limits | `tmux list-sessions \| wc -l` | Clean up test sessions: `tmux list-sessions \| grep test \| awk -F: '{print $1}' \| xargs -I{} tmux kill-session -t {}` |
| Tests failing on session limits | `tmux list-sessions \| wc -l` | Clean up: `tmux list-sessions \| grep test \| awk -F: '{print $1}' \| xargs -I{} tmux kill-session -t {}` |
| State not persisting | `cat ~/.claudeman/state.json` | Check file permissions, disk space |
## Performance Constraints
@@ -296,7 +287,7 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base
| **Test utilities** | `test/respawn-test-utils.ts` |
| **Memory leak patterns** | `test/memory-leak-prevention.test.ts` |
| **Keyboard shortcuts** | README.md or App Settings in web UI |
| **Mobile/SSH access** | README.md (Claudeman Screens / `sc` command) |
| **Mobile/SSH access** | README.md (Claudeman Sessions / `sc` command) |
| **Plan orchestrator** | `src/plan-orchestrator.ts` file header |
| **Agent prompts** | `src/prompts/` directory |
| **Agent Teams (experimental)** | `agent-teams/README.md`, `agent-teams/design.md` |
@@ -307,8 +298,6 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base
|--------|---------|
| `scripts/tmux-manager.sh` | Safe tmux session management (use instead of direct kill commands) |
| `scripts/tmux-chooser.sh` | Mobile-friendly tmux session picker (`sc` alias) |
| `scripts/screen-manager.sh` | Legacy screen management (deprecated, use tmux-manager.sh) |
| `scripts/screen-chooser.sh` | Legacy screen session picker (deprecated, use tmux-chooser.sh) |
| `scripts/monitor-respawn.sh` | Monitor respawn state machine in real-time |
| `scripts/postinstall.js` | npm postinstall hook for setup |
| `scripts/data-generator.sh` | Generate test data for development |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claudeman",
"version": "0.1487",
"version": "0.1488",
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+1 -14
View File
@@ -149,25 +149,12 @@ 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 {
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;
return AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer);
}
protected parseVerdict(output: string): { verdict: AiCheckVerdict; reasoning: string } | null {
+33 -3
View File
@@ -412,7 +412,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
processTerminalData(data: string): void {
if (!this._enabled || this._destroyed) return;
// Append to line buffer
// Append to line buffer (raw data — lines will be stripped individually in processLine)
this._lineBuffer += data;
// Prevent unbounded growth
@@ -434,14 +434,44 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
}
}
/**
* Process pre-stripped terminal data (ANSI codes already removed).
* Use this when the caller has already stripped ANSI to avoid redundant regex work.
*/
processCleanData(data: string): void {
if (!this._enabled || this._destroyed) return;
this._lineBuffer += data;
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
this._lineBuffer = trimPoint > 0
? this._lineBuffer.slice(trimPoint + 1)
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
}
const lines = this._lineBuffer.split('\n');
this._lineBuffer = lines.pop() || '';
for (const line of lines) {
this.processCleanLine(line);
}
}
// ========== Private Methods ==========
/**
* Process a single line of terminal output.
* Process a single line of terminal output (raw — will strip ANSI).
*/
private processLine(line: string): void {
// Strip ANSI codes for cleaner pattern matching
const cleanLine = this.stripAnsi(line);
this.processCleanLine(cleanLine);
}
/**
* Process a single pre-stripped line of terminal output.
*/
private processCleanLine(cleanLine: string): void {
// Check for tool start
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
+13
View File
@@ -10,15 +10,28 @@
import { program } from './cli.js';
// Detect if we're running the web server (long-lived process)
// In web mode, we should NOT exit on transient errors — log and continue
const isWebMode = process.argv.includes('web');
// Handle uncaught errors
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err.message);
if (isWebMode) {
// Log full stack trace for debugging but keep the server running
console.error('[RECOVERED] Server continuing after uncaught exception:', err.stack);
} else {
process.exit(1);
}
});
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
if (isWebMode) {
console.error('[RECOVERED] Server continuing after unhandled rejection');
} else {
process.exit(1);
}
});
// Run CLI
+8
View File
@@ -1317,6 +1317,14 @@ export class RalphTracker extends EventEmitter {
processTerminalData(data: string): void {
// Remove ANSI escape codes for cleaner parsing
const cleanData = data.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
this.processCleanData(cleanData);
}
/**
* Process pre-stripped terminal data (ANSI codes already removed).
* Use this when the caller has already stripped ANSI to avoid redundant regex work.
*/
processCleanData(cleanData: string): void {
// If tracker is disabled, only check for patterns that should auto-enable it
if (!this._loopState.enabled) {
-25
View File
@@ -57,7 +57,6 @@ import type {
CycleOutcome,
HealthStatus,
} from './types.js';
import type { TeamWatcher } from './team-watcher.js';
// ========== Constants ==========
@@ -639,9 +638,6 @@ 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;
@@ -1235,10 +1231,6 @@ 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');
@@ -2191,12 +2183,6 @@ 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') {
@@ -2750,17 +2736,6 @@ 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}%, ` +
+11 -4
View File
@@ -430,6 +430,13 @@ export class Session extends EventEmitter {
niceConfig?: NiceConfig; // Nice prioritying configuration
}) {
super();
// Default error handler prevents unhandled 'error' events from crashing the process.
// Server attaches its own handler after construction — this is a safety net for the gap.
this.on('error', (err) => {
console.error(`[Session] Unhandled error event:`, err);
});
this.id = config.id || uuidv4();
this.workingDir = config.workingDir;
this.createdAt = config.createdAt || Date.now();
@@ -1016,11 +1023,11 @@ export class Session extends EventEmitter {
this.emit('terminal', data);
this.emit('output', data);
// Forward to Ralph tracker to detect Ralph loops and todos (handles own stripping)
this._ralphTracker.processTerminalData(data);
// Forward to Ralph tracker to detect Ralph loops and todos (pre-stripped)
this._ralphTracker.processCleanData(cleanData);
// Forward to Bash tool parser to detect file-viewing commands (handles own stripping)
this._bashToolParser.processTerminalData(data);
// Forward to Bash tool parser to detect file-viewing commands (pre-stripped)
this._bashToolParser.processCleanData(cleanData);
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
this.parseTokensFromStatusLine(cleanData);
+90 -28
View File
@@ -15,6 +15,7 @@
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js';
@@ -105,6 +106,7 @@ export class StateStore {
/**
* Schedules a debounced save.
* Multiple calls within 500ms are batched into a single disk write.
* Uses async I/O to avoid blocking the event loop.
*/
save(): void {
this.dirty = true;
@@ -112,17 +114,18 @@ export class StateStore {
return; // Already scheduled
}
this.saveTimeout = setTimeout(() => {
this.saveNow();
this.saveNowAsync().catch((err) => {
console.error('[StateStore] Async save failed:', err);
});
}, SAVE_DEBOUNCE_MS);
}
/**
* Immediately writes state to disk using atomic write pattern.
* Writes to temp file first, then renames to prevent corruption on crash.
* Includes backup mechanism and circuit breaker for reliability.
* Use when guaranteed persistence is required (e.g., before shutdown).
* Async version of saveNow — used by the debounced save() path.
* Uses non-blocking fs.promises to avoid blocking the event loop during
* the debounced write cycle. For synchronous shutdown flush, use saveNow().
*/
saveNow(): void {
async saveNowAsync(): Promise<void> {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
@@ -146,7 +149,7 @@ export class StateStore {
// Step 1: Serialize state (validates it's JSON-safe)
try {
json = JSON.stringify(this.state, null, 2);
json = JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
@@ -154,29 +157,22 @@ export class StateStore {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
// Don't throw - this prevents crashing the app
// Mark dirty again so we can retry later
this.dirty = true;
return;
}
// Step 2: Create backup of current state file (if exists)
// Step 2: Create backup via file copy (async, no read+parse+write)
try {
if (existsSync(this.filePath)) {
// Read current file and verify it's valid JSON before backing up
const currentContent = readFileSync(this.filePath, 'utf-8');
JSON.parse(currentContent); // Validate
writeFileSync(backupPath, currentContent, 'utf-8');
}
} catch (err) {
// Backup failed - current file may be corrupt, continue with write
console.warn('[StateStore] Could not create backup (current file may be corrupt):', err);
await access(this.filePath);
await copyFile(this.filePath, backupPath);
} catch {
// Backup failed or file doesn't exist yet - continue with write
}
// Step 3: Atomic write: write to temp file, then rename
// Step 3: Atomic write: write to temp file, then rename (async)
try {
writeFileSync(tempPath, json, 'utf-8');
renameSync(tempPath, this.filePath);
await writeFile(tempPath, json, 'utf-8');
await rename(tempPath, this.filePath);
// Success! Reset failure counter
this.consecutiveSaveFailures = 0;
@@ -190,11 +186,9 @@ export class StateStore {
// Try to clean up temp file on error
try {
if (existsSync(tempPath)) {
unlinkSync(tempPath);
}
} catch (cleanupErr) {
console.warn('[StateStore] Failed to cleanup temp file during save error:', cleanupErr);
await unlink(tempPath);
} catch {
// Temp file may not exist
}
// Check circuit breaker threshold
@@ -203,7 +197,75 @@ export class StateStore {
this.circuitBreakerOpen = true;
}
// Mark dirty so we retry later (don't throw to avoid crashing app)
this.dirty = true;
}
}
/**
* Synchronous immediate write to disk using atomic write pattern.
* Used by flushAll() during shutdown when async is not appropriate.
* Prefer saveNowAsync() for normal operation.
*/
saveNow(): void {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
if (!this.dirty) {
return;
}
if (this.circuitBreakerOpen) {
console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)');
return;
}
this.dirty = false;
this.ensureDir();
const tempPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak';
let json: string;
try {
json = JSON.stringify(this.state);
} catch (err) {
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
this.consecutiveSaveFailures++;
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
this.circuitBreakerOpen = true;
}
this.dirty = true;
return;
}
// Backup via copy (skip read+parse validation — just copy the file)
try {
if (existsSync(this.filePath)) {
const currentContent = readFileSync(this.filePath, 'utf-8');
writeFileSync(backupPath, currentContent, 'utf-8');
}
} catch {
// Backup failed - continue with write
}
try {
writeFileSync(tempPath, json, 'utf-8');
renameSync(tempPath, this.filePath);
this.consecutiveSaveFailures = 0;
if (this.circuitBreakerOpen) {
console.log('[StateStore] Circuit breaker CLOSED - save succeeded');
this.circuitBreakerOpen = false;
}
} catch (err) {
console.error('[StateStore] Failed to write state file:', err);
this.consecutiveSaveFailures++;
try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* ignore */ }
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly');
this.circuitBreakerOpen = true;
}
this.dirty = true;
}
}
+22 -16
View File
@@ -11,7 +11,8 @@ import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { homedir } from 'node:os';
import { join, basename } from 'node:path';
import { execSync } from 'node:child_process';
import { execFile } from 'node:child_process';
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS } from './config/map-limits.js';
// ========== Types ==========
@@ -208,11 +209,11 @@ export class SubagentWatcher extends EventEmitter {
this._isRunning = true;
// Initial scan
this.scanForSubagents();
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
// Periodic scan for new subagent directories
this.pollInterval = setInterval(() => {
this.scanForSubagents();
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
}, POLL_INTERVAL_MS);
// Periodic liveness check for active subagents
@@ -583,8 +584,13 @@ export class SubagentWatcher extends EventEmitter {
*/
private async findSubagentProcess(sessionId: string): Promise<number | null> {
try {
// Find all claude processes
const pgrepOutput = execSync('pgrep -f "claude"', { encoding: 'utf8' });
// Find all claude processes (async to avoid blocking event loop)
const pgrepOutput = await new Promise<string>((resolve, reject) => {
execFile('pgrep', ['-f', 'claude'], { encoding: 'utf8' }, (err, stdout) => {
if (err) return reject(err);
resolve(stdout);
});
});
const pids = pgrepOutput.trim().split('\n').filter(Boolean);
for (const pidStr of pids) {
@@ -592,8 +598,8 @@ export class SubagentWatcher extends EventEmitter {
if (Number.isNaN(pid)) continue;
try {
// Check /proc/{pid}/environ for session ID
const environ = readFileSync(`/proc/${pid}/environ`, 'utf8');
// Check /proc/{pid}/environ for session ID (async read)
const environ = await readFile(`/proc/${pid}/environ`, 'utf8');
if (environ.includes(sessionId)) {
return pid;
}
@@ -602,8 +608,8 @@ export class SubagentWatcher extends EventEmitter {
}
try {
// Also check /proc/{pid}/cmdline for session ID
const cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
// Also check /proc/{pid}/cmdline for session ID (async read)
const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8');
if (cmdline.includes(sessionId)) {
return pid;
}
@@ -850,28 +856,28 @@ export class SubagentWatcher extends EventEmitter {
}
/**
* Scan for all subagent directories
* Scan for all subagent directories (async to avoid blocking event loop)
*/
private scanForSubagents(): void {
private async scanForSubagents(): Promise<void> {
if (!existsSync(CLAUDE_PROJECTS_DIR)) return;
try {
const projects = readdirSync(CLAUDE_PROJECTS_DIR);
const projects = await readdir(CLAUDE_PROJECTS_DIR);
for (const project of projects) {
const projectPath = join(CLAUDE_PROJECTS_DIR, project);
try {
const stat = statSync(projectPath);
if (!stat.isDirectory()) continue;
const st = await statAsync(projectPath);
if (!st.isDirectory()) continue;
const sessions = readdirSync(projectPath);
const sessions = await readdir(projectPath);
for (const session of sessions) {
const sessionPath = join(projectPath, session);
try {
const sessionStat = statSync(sessionPath);
const sessionStat = await statAsync(sessionPath);
if (!sessionStat.isDirectory()) continue;
const subagentDir = join(sessionPath, 'subagents');
-66
View File
@@ -1533,72 +1533,6 @@ export interface PaneInfo {
height: 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';
+52 -112
View File
@@ -1145,14 +1145,6 @@ class ClaudemanApp {
// Once set, never recalculated. Persisted to localStorage and server.
this.subagentParentMap = new Map();
// Agent Teams tracking
this.teams = new Map(); // Map<teamName, TeamConfig>
this.teamTasks = new Map(); // Map<teamName, TeamTask[]>
this.teammateMap = new Map(); // Map<agentId-prefix, {name, color, teamName}> for quick lookup
// Teammate tmux pane terminals
this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }>
this.teammateTerminals = new Map(); // Map<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }>
this.ralphStatePanelCollapsed = true; // Default to collapsed
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
@@ -2108,22 +2100,6 @@ class ClaudemanApp {
this.closeSessionImagePopups(data.id); // Close image popup windows for this session
this.closeSessionSubagentWindows(data.id, true); // Close subagent windows and cleanup activity data
// Clean up any agent teams associated with this session
for (const [teamName, team] of this.teams) {
if (team.leadSessionId === data.id) {
this.teams.delete(teamName);
this.teamTasks.delete(teamName);
}
}
// Clean up teammate pane tracking for this session
for (const [name, paneInfo] of this.teammatePanesByName) {
if (paneInfo.sessionId === data.id) {
this.teammatePanesByName.delete(name);
}
}
this.rebuildTeammateMap();
this.renderTeamTasksPanel();
// Clean up idle timer for this session
const idleTimer = this.idleTimers.get(data.id);
if (idleTimer) {
@@ -2892,81 +2868,6 @@ class ClaudemanApp {
}, 5 * 60 * 1000); // 5 minutes
});
// ========== Agent Teams Events ==========
addListener('team:created', (e) => {
const team = JSON.parse(e.data);
this.teams.set(team.name, team);
this.rebuildTeammateMap();
this.renderTeamTasksPanel();
this.updateSubagentWindows();
});
addListener('team:updated', (e) => {
const team = JSON.parse(e.data);
this.teams.set(team.name, team);
this.rebuildTeammateMap();
this.renderTeamTasksPanel();
this.updateSubagentWindows();
});
addListener('team:removed', (e) => {
const team = JSON.parse(e.data);
this.teams.delete(team.name);
this.teamTasks.delete(team.name);
this.rebuildTeammateMap();
this.renderTeamTasksPanel();
this.updateSubagentWindows();
});
addListener('team:task_updated', (e) => {
const data = JSON.parse(e.data);
this.teamTasks.set(data.teamName, data.tasks);
this.renderTeamTasksPanel();
});
addListener('team:inbox_message', (e) => {
// Inbox messages currently just trigger panel updates
this.renderTeamTasksPanel();
});
// ========== Teammate Pane Events ==========
addListener('teammate:pane_available', (e) => {
const data = JSON.parse(e.data);
// Only show teammate pane windows if the session has a tab in Claudeman
if (!this.sessions.has(data.sessionId)) return;
this.teammatePanesByName.set(data.teammateName, {
paneTarget: data.paneTarget,
sessionId: data.sessionId,
color: data.color,
});
// Teammate pane windows are disabled — users interact with teammates
// directly through the main tmux split-pane terminal instead.
});
addListener('teammate:terminal', (e) => {
const data = JSON.parse(e.data);
// Find the terminal instance for this pane
for (const [, termData] of this.teammateTerminals) {
if (termData.paneTarget === data.paneTarget && termData.sessionId === data.sessionId) {
// Decode base64 to Uint8Array to preserve UTF-8 multi-byte sequences
const binary = atob(data.data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
if (termData.terminal) {
try { termData.terminal.write(bytes); } catch {}
} else if (termData.pendingData) {
// Terminal not ready yet — buffer the data
termData.pendingData.push(bytes);
}
break;
}
}
});
// ========== Image Detection Events (Screenshots & Generated Images) ==========
addListener('image:detected', (e) => {
@@ -11285,6 +11186,16 @@ class ClaudemanApp {
}
renderSubagentPanel() {
// Debounce renders at 150ms to prevent excessive DOM updates from rapid subagent events
if (this._subagentPanelRenderTimeout) {
clearTimeout(this._subagentPanelRenderTimeout);
}
this._subagentPanelRenderTimeout = setTimeout(() => {
this._renderSubagentPanelImmediate();
}, 150);
}
_renderSubagentPanelImmediate() {
const list = this.$('subagentList');
if (!list) return;
@@ -11777,6 +11688,17 @@ class ClaudemanApp {
// This map stores agentId -> sessionId, where sessionId is the tab's data-id.
updateConnectionLines() {
// Coalesce multiple calls within the same frame into a single rAF
if (!this._connectionLinesScheduled) {
this._connectionLinesScheduled = true;
requestAnimationFrame(() => {
this._connectionLinesScheduled = false;
this._updateConnectionLinesImmediate();
});
}
}
_updateConnectionLinesImmediate() {
const svg = document.getElementById('connectionLines');
if (!svg) return;
@@ -12007,13 +11929,6 @@ class ClaudemanApp {
// ========== Subagent Floating Windows ==========
openSubagentWindow(agentId) {
// Skip teammate agents — they use tmux split panes in the main terminal,
// not floating popup windows. Prevents false-info activity log popups.
const agentForTeammateCheck = this.subagents.get(agentId);
if (agentForTeammateCheck && this.getTeammateInfo(agentForTeammateCheck)) {
return;
}
// If window already exists, focus it
if (this.subagentWindows.has(agentId)) {
const existing = this.subagentWindows.get(agentId);
@@ -12581,7 +12496,37 @@ class ClaudemanApp {
return;
}
const html = activity.slice(-100).map(a => {
// Incremental rendering: track how many items are already rendered
const renderedCount = body.dataset.renderedCount ? parseInt(body.dataset.renderedCount, 10) : 0;
const maxItems = 100;
const visibleActivity = activity.slice(-maxItems);
// If activity was trimmed or this is a fresh render, do full rebuild
if (renderedCount === 0 || renderedCount > visibleActivity.length || body.children.length === 0 ||
(body.children.length === 1 && body.querySelector('.subagent-empty'))) {
// Full rebuild
const html = visibleActivity.map(a => this._renderActivityItem(a)).join('');
body.innerHTML = html;
body.dataset.renderedCount = String(visibleActivity.length);
} else {
// Incremental: only append new items
const newItems = visibleActivity.slice(renderedCount);
if (newItems.length > 0) {
const newHtml = newItems.map(a => this._renderActivityItem(a)).join('');
body.insertAdjacentHTML('beforeend', newHtml);
body.dataset.renderedCount = String(visibleActivity.length);
// Trim excess children from the front if over maxItems
while (body.children.length > maxItems) {
body.removeChild(body.firstChild);
}
}
}
body.scrollTop = body.scrollHeight;
}
_renderActivityItem(a) {
const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false });
if (a.type === 'tool') {
return `<div class="activity-line">
@@ -12602,7 +12547,6 @@ class ClaudemanApp {
<span class="tool-detail">${this.escapeHtml(preview)}${sizeInfo}</span>
</div>`;
} else if (a.type === 'progress') {
// Check for hook events
const isHook = a.hookEvent || a.hookName;
const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓');
const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType);
@@ -12618,10 +12562,6 @@ class ClaudemanApp {
</div>`;
}
return '';
}).join('');
body.innerHTML = html;
body.scrollTop = body.scrollHeight;
}
// Update all open subagent windows
-15
View File
@@ -425,21 +425,6 @@
</div>
</div>
<!-- Team Tasks Panel -->
<div class="team-tasks-panel" id="teamTasksPanel" style="display: none;">
<div class="team-tasks-header">
<span class="team-tasks-header-text">Team Tasks</span>
<div class="team-tasks-progress">
<div class="team-tasks-progress-bar">
<div class="team-tasks-progress-fill"></div>
</div>
<span class="team-tasks-progress-text">0/0</span>
</div>
<button class="team-tasks-close" onclick="app.hideTeamTasksPanel()" title="Hide">─</button>
</div>
<div class="team-tasks-list"></div>
</div>
<!-- Session Options Modal (includes Respawn Settings) -->
<div class="modal" id="sessionOptionsModal">
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
-195
View File
@@ -5317,20 +5317,6 @@ kbd {
scroll-behavior: smooth;
}
/* Teammate terminal pane (xterm.js embedded in subagent window) */
.subagent-window-body.teammate-terminal-body {
padding: 0 !important;
overflow: hidden !important;
}
.subagent-window-body.teammate-terminal-body .xterm {
height: 100%;
}
/* Larger default size for terminal windows */
.subagent-window.has-terminal {
width: 550px;
height: 400px;
}
/* Custom scrollbar for subagent window body */
.subagent-window-body::-webkit-scrollbar {
width: 6px;
@@ -5499,187 +5485,6 @@ kbd {
50% { opacity: 1; }
}
/* ========== Agent Teams (Teammate Badges + Team Tasks Panel) ========== */
/* Teammate badge in subagent panel/windows */
.teammate-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 8px;
font-weight: 600;
margin-left: 4px;
white-space: nowrap;
}
.teammate-color-blue {
background: #3b82f6;
color: #fff;
}
.teammate-color-green {
background: #22c55e;
color: #fff;
}
.teammate-color-yellow {
background: #eab308;
color: #1a1a2e;
}
/* Teammate dot icon replacing robot emoji */
.teammate-dot {
font-size: 14px;
}
.teammate-dot.teammate-color-blue { color: #3b82f6; }
.teammate-dot.teammate-color-green { color: #22c55e; }
.teammate-dot.teammate-color-yellow { color: #eab308; }
/* Subagent items that are teammates get a subtle left border */
.subagent-item.is-teammate {
border-left: 2px solid #3b82f6;
}
.subagent-item.is-teammate .teammate-color-green ~ * { border-left-color: #22c55e; }
/* Team Tasks Panel */
.team-tasks-panel {
position: fixed;
width: 360px;
min-width: 260px;
min-height: 120px;
max-width: 90vw;
max-height: 80vh;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 900;
overflow: hidden;
font-size: 12px;
display: flex;
flex-direction: column;
resize: both;
}
/* Resize handle indicator */
.team-tasks-panel::after {
content: '';
position: absolute;
bottom: 4px;
right: 4px;
width: 12px;
height: 12px;
background: linear-gradient(135deg, transparent 50%, var(--border-light) 50%, var(--border-light) 60%, transparent 60%, transparent 70%, var(--border-light) 70%, var(--border-light) 80%, transparent 80%);
pointer-events: none;
opacity: 0.6;
}
.team-tasks-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-input);
border-bottom: 1px solid var(--border);
cursor: move;
gap: 8px;
}
.team-tasks-close {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.2rem 0.4rem;
font-size: 0.9rem;
flex-shrink: 0;
}
.team-tasks-close:hover {
color: var(--text);
}
.team-tasks-header-text {
font-weight: 600;
color: var(--text-primary);
}
.team-tasks-progress {
display: flex;
align-items: center;
gap: 6px;
}
.team-tasks-progress-bar {
width: 60px;
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
overflow: hidden;
}
.team-tasks-progress-fill {
height: 100%;
background: var(--success);
border-radius: 3px;
transition: width 0.3s ease;
}
.team-tasks-progress-text {
color: var(--text-secondary);
font-size: 11px;
}
.team-tasks-list {
overflow-y: auto;
flex: 1;
min-height: 0;
padding: 4px 0;
}
.team-task-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
color: var(--text-primary);
}
.team-task-item.completed {
opacity: 0.6;
}
.team-task-item.in-progress .team-task-status {
color: var(--accent);
}
.team-task-status {
flex-shrink: 0;
width: 14px;
text-align: center;
}
.team-task-subject {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team-task-owner {
font-size: 10px;
padding: 1px 5px;
border-radius: 8px;
font-weight: 600;
flex-shrink: 0;
}
.team-task-empty {
padding: 12px;
text-align: center;
color: var(--text-secondary);
}
/* ========== Project Insights Panel (Bash File Viewers) ========== */
.project-insights-panel {
-11
View File
@@ -140,14 +140,3 @@ export const SessionInputWithLimitSchema = z.object({
input: z.string().max(100000), // 100KB max input
useScreen: z.boolean().optional(),
});
// ========== Teammate Pane Routes ==========
/**
* Schema for POST /api/sessions/:id/teammate-pane-input
* Sends keyboard input to a teammate's tmux pane.
*/
export const TeammatePaneInputSchema = z.object({
paneTarget: z.string().regex(/^(%\d+|\d+)$/, 'Invalid pane target format'),
input: z.string().max(100000),
});
+13 -313
View File
@@ -36,7 +36,6 @@ import { v4 as uuidv4 } from 'uuid';
import { createRequire } from 'node:module';
import { RunSummaryTracker } from '../run-summary.js';
import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js';
import { TeamWatcher } from '../team-watcher.js';
// Load version from package.json
const require = createRequire(import.meta.url);
@@ -54,9 +53,6 @@ import {
type PersistedRespawnConfig,
type NiceConfig,
type ImageDetectedEvent,
type TeamConfig,
type TeamTask,
type InboxMessage,
DEFAULT_NICE_CONFIG,
} from '../types.js';
import {
@@ -69,7 +65,6 @@ import {
HookEventSchema,
ConfigUpdateSchema,
RespawnConfigSchema,
TeammatePaneInputSchema,
} from './schemas.js';
import { StaleExpirationMap } from '../utils/index.js';
@@ -377,11 +372,6 @@ export class WebServer extends EventEmitter {
detected: (event: ImageDetectedEvent) => void;
error: (error: Error, sessionId?: string) => void;
} | null = null;
private teamWatcher: TeamWatcher = new TeamWatcher();
// Pane streaming for teammate tmux panes: keyed by "sessionId:paneTarget"
private paneStreams: Map<string, { tailProcess: ChildProcess; outputFile: string; muxName: string; paneTarget: string; sessionId: string; teammateName: string }> = new Map();
private paneStreamTempDir: string | null = null;
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
super();
this.port = port;
@@ -504,176 +494,6 @@ export class WebServer extends EventEmitter {
}
}
private setupTeamWatcherListeners(): void {
this.teamWatcher.on('teamCreated', (team: TeamConfig) => {
this.broadcast('team:created', team);
this.startPaneStreamsForTeam(team);
});
this.teamWatcher.on('teamUpdated', (team: TeamConfig) => {
this.broadcast('team:updated', team);
this.startPaneStreamsForTeam(team);
});
this.teamWatcher.on('teamRemoved', (team: TeamConfig) => {
this.broadcast('team:removed', team);
this.stopPaneStreamsForSession(team.leadSessionId);
});
this.teamWatcher.on('taskUpdated', (data: { teamName: string; tasks: TeamTask[] }) => {
// Filter out internal tasks for broadcast
const visibleTasks = data.tasks.filter(t => !t.metadata?._internal);
this.broadcast('team:task_updated', { teamName: data.teamName, tasks: visibleTasks });
});
this.teamWatcher.on('inboxMessage', (data: { teamName: string; member: string; message: InboxMessage }) => {
this.broadcast('team:inbox_message', data);
});
}
/** Start pane streams for any tmux-pane teammates in a team */
private startPaneStreamsForTeam(team: TeamConfig): void {
if (this.mux.backend !== 'tmux') return;
const tmuxMgr = this.mux as TmuxManager;
// Find the mux session for this team's lead
const muxSession = tmuxMgr.getSession(team.leadSessionId);
if (!muxSession) return;
// Enable mouse mode so users can click between panes in the main terminal
tmuxMgr.enableMouseMode(muxSession.muxName);
let paneTeammates = this.teamWatcher.getTmuxPaneTeammates(team.name);
// Auto-discover panes if config.json doesn't include tmuxPaneId.
// When Claude Code creates agent team split panes in tmux mode, the panes
// exist in the tmux session but config.json may not include pane IDs.
// We discover them by querying tmux and matching to non-lead teammates.
if (paneTeammates.length === 0) {
const nonLeadMembers = team.members.filter(m => m.agentType !== 'team-lead');
if (nonLeadMembers.length === 0) return;
const panes = tmuxMgr.listPanes(muxSession.muxName);
// Skip pane index 0 (leader's pane) — remaining panes are teammates
const teammatePanes = panes.filter(p => p.paneIndex > 0).sort((a, b) => a.paneIndex - b.paneIndex);
if (teammatePanes.length > 0) {
// Match discovered panes to teammates in order
const discovered: Array<typeof team.members[0] & { tmuxPaneId: string }> = [];
for (let i = 0; i < Math.min(teammatePanes.length, nonLeadMembers.length); i++) {
const paneId = teammatePanes[i].paneId; // e.g., "%1"
const member = nonLeadMembers[i];
// Write back to member for future lookups
member.tmuxPaneId = paneId;
discovered.push({ ...member, tmuxPaneId: paneId });
}
paneTeammates = discovered;
console.log(`[Server] Auto-discovered ${discovered.length} teammate panes via tmux list-panes`);
}
}
if (paneTeammates.length === 0) return;
// Ensure temp directory exists
if (!this.paneStreamTempDir) {
this.paneStreamTempDir = mkdtempSync(join(tmpdir(), 'claudeman-panes-'));
}
for (const teammate of paneTeammates) {
const streamKey = `${team.leadSessionId}:${teammate.tmuxPaneId}`;
// Skip if stream already exists
if (this.paneStreams.has(streamKey)) continue;
const outputFile = join(this.paneStreamTempDir, `pane-${teammate.name}-${Date.now()}.raw`);
// Create the output file so tail -f doesn't error
writeFileSync(outputFile, '');
// Start tmux pipe-pane to redirect pane output to the file
const started = tmuxMgr.startPipePane(muxSession.muxName, teammate.tmuxPaneId, outputFile);
if (!started) {
console.error(`[Server] Failed to start pipe-pane for teammate ${teammate.name}`);
continue;
}
// Start tail -f to stream the output file contents
const tailProcess = spawn('tail', ['-f', '-n', '0', outputFile], {
stdio: ['ignore', 'pipe', 'ignore'],
});
tailProcess.stdout?.on('data', (chunk: Buffer) => {
this.broadcast('teammate:terminal', {
sessionId: team.leadSessionId,
teammateName: teammate.name,
paneTarget: teammate.tmuxPaneId,
data: chunk.toString('base64'),
});
});
tailProcess.on('error', (err) => {
console.error(`[Server] Pane stream tail error for ${teammate.name}:`, err.message);
});
this.paneStreams.set(streamKey, {
tailProcess,
outputFile,
muxName: muxSession.muxName,
paneTarget: teammate.tmuxPaneId,
sessionId: team.leadSessionId,
teammateName: teammate.name,
});
// Notify frontend that a pane is available
this.broadcast('teammate:pane_available', {
sessionId: team.leadSessionId,
teammateName: teammate.name,
paneTarget: teammate.tmuxPaneId,
color: teammate.color || 'blue',
});
console.log(`[Server] Started pane stream for teammate ${teammate.name} (pane ${teammate.tmuxPaneId})`);
}
}
/** Stop all pane streams for a session */
private stopPaneStreamsForSession(sessionId: string): void {
if (this.mux.backend !== 'tmux') return;
const tmuxMgr = this.mux as TmuxManager;
for (const [key, stream] of this.paneStreams) {
if (stream.sessionId === sessionId) {
// Kill the tail process
try { stream.tailProcess.kill('SIGTERM'); } catch { /* already dead */ }
// Stop tmux pipe-pane
tmuxMgr.stopPipePane(stream.muxName, stream.paneTarget);
// Remove output file
try { unlinkSync(stream.outputFile); } catch { /* ignore */ }
this.paneStreams.delete(key);
}
}
}
/** Stop all pane streams and clean up temp directory */
private cleanupAllPaneStreams(): void {
if (this.mux.backend === 'tmux') {
const tmuxMgr = this.mux as TmuxManager;
for (const [, stream] of this.paneStreams) {
try { stream.tailProcess.kill('SIGTERM'); } catch { /* already dead */ }
tmuxMgr.stopPipePane(stream.muxName, stream.paneTarget);
try { unlinkSync(stream.outputFile); } catch { /* ignore */ }
}
} else {
for (const [, stream] of this.paneStreams) {
try { stream.tailProcess.kill('SIGTERM'); } catch { /* already dead */ }
try { unlinkSync(stream.outputFile); } catch { /* ignore */ }
}
}
this.paneStreams.clear();
// Remove temp directory
if (this.paneStreamTempDir) {
try { rmSync(this.paneStreamTempDir, { recursive: true, force: true }); } catch { /* ignore */ }
this.paneStreamTempDir = null;
}
}
private async setupRoutes(): Promise<void> {
// Serve static files
await this.app.register(fastifyStatic, {
@@ -697,18 +517,6 @@ export class WebServer extends EventEmitter {
// Buffers are fetched on-demand when switching tabs
this.sendSSE(reply, 'init', this.getLightState());
// Send pane_available events for any active pane streams
for (const stream of this.paneStreams.values()) {
const team = this.teamWatcher?.getTeamForSession(stream.sessionId);
const member = team?.members.find(m => m.name === stream.teammateName);
this.sendSSE(reply, 'teammate:pane_available', {
sessionId: stream.sessionId,
teammateName: stream.teammateName,
paneTarget: stream.paneTarget,
color: member?.color || 'blue',
});
}
req.raw.on('close', () => {
this.sseClients.delete(reply);
});
@@ -843,7 +651,7 @@ export class WebServer extends EventEmitter {
});
// Session management
this.app.get('/api/sessions', async () => this.getSessionsState());
this.app.get('/api/sessions', async () => this.getLightSessionsState());
this.app.post('/api/sessions', async (req): Promise<SessionResponse> => {
// Prevent unbounded session creation
@@ -3503,106 +3311,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
});
// ========== Agent Teams ==========
// List all active teams
this.app.get('/api/teams', async () => {
const teams = this.teamWatcher.getTeams();
// Enrich with session association
const enriched = teams.map(team => ({
...team,
sessionExists: this.sessions.has(team.leadSessionId),
activeTasks: this.teamWatcher.getActiveTaskCount(team.name),
teammateCount: team.members.filter(m => m.agentType !== 'team-lead').length,
}));
return { success: true, data: enriched };
});
// Get team for a specific session
this.app.get('/api/sessions/:id/team', async (req) => {
const { id } = req.params as { id: string };
const team = this.teamWatcher.getTeamForSession(id);
if (!team) {
return { success: true, data: null };
}
return {
success: true,
data: {
...team,
activeTasks: this.teamWatcher.getActiveTaskCount(team.name),
teammateCount: team.members.filter(m => m.agentType !== 'team-lead').length,
},
};
});
// Get team tasks for a session's team
this.app.get('/api/sessions/:id/team-tasks', async (req) => {
const { id } = req.params as { id: string };
const team = this.teamWatcher.getTeamForSession(id);
if (!team) {
return { success: true, data: [] };
}
const tasks = this.teamWatcher.getTeamTasks(team.name);
return { success: true, data: tasks };
});
// ========== Teammate Pane Endpoints ==========
// List teammate panes for a session
this.app.get('/api/sessions/:id/teammate-panes', async (req) => {
const { id } = req.params as { id: string };
const team = this.teamWatcher.getTeamForSession(id);
if (!team) {
return { success: true, data: [] };
}
const paneTeammates = this.teamWatcher.getTmuxPaneTeammates(team.name);
const panes = paneTeammates.map(t => ({
teammateName: t.name,
paneTarget: t.tmuxPaneId,
color: t.color || 'blue',
hasStream: this.paneStreams.has(`${id}:${t.tmuxPaneId}`),
}));
return { success: true, data: panes };
});
// Get pane buffer for a teammate
this.app.get('/api/sessions/:id/teammate-pane-buffer/:paneTarget', async (req) => {
const { id, paneTarget } = req.params as { id: string; paneTarget: string };
if (this.mux.backend !== 'tmux') {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Pane access requires tmux backend');
}
const tmuxMgr = this.mux as TmuxManager;
const muxSession = tmuxMgr.getSession(id);
if (!muxSession) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const buffer = tmuxMgr.capturePaneBuffer(muxSession.muxName, paneTarget);
if (buffer === null) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to capture pane buffer');
}
return { success: true, data: { buffer } };
});
// Send input to a teammate's pane
this.app.post('/api/sessions/:id/teammate-pane-input', async (req) => {
const { id } = req.params as { id: string };
const result = TeammatePaneInputSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { paneTarget, input } = result.data;
if (this.mux.backend !== 'tmux') {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Pane access requires tmux backend');
}
const tmuxMgr = this.mux as TmuxManager;
const muxSession = tmuxMgr.getSession(id);
if (!muxSession) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const success = tmuxMgr.sendInputToPane(muxSession.muxName, paneTarget, input);
return { success };
});
// ========== Hook Events ==========
this.app.post('/api/hook-event', async (req) => {
@@ -3829,14 +3537,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
}
// Clean up any agent team associated with this session
// Stop pane streams and notify frontend so team tasks/windows are removed
this.stopPaneStreamsForSession(sessionId);
const team = this.teamWatcher.getTeamForSession(sessionId);
if (team) {
this.broadcast('team:removed', team);
}
// Stop and remove respawn controller - but save config first for restart recovery
const controller = this.respawnControllers.get(sessionId);
if (controller) {
@@ -4227,9 +3927,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
// Inject team watcher for team-aware idle detection
controller.setTeamWatcher(this.teamWatcher);
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
const getTracker = () => this.runSummaryTrackers.get(sessionId);
@@ -5037,10 +4734,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
console.log('Image watcher disabled by user settings');
}
// Start team watcher for Agent Teams support
this.setupTeamWatcherListeners();
this.teamWatcher.start();
console.log('Team watcher started - monitoring ~/.claude/teams for agent team activity');
}
/**
@@ -5336,14 +5029,21 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Stop image watcher
imageWatcher.stop();
// Stop team watcher and clean up pane streams
this.cleanupAllPaneStreams();
this.teamWatcher.removeAllListeners();
this.teamWatcher.stop();
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
fileStreamManager.destroy();
// Stop all remaining tracked resources before clearing their Maps
for (const tracker of this.runSummaryTrackers.values()) {
tracker.stop();
}
for (const watcher of this.transcriptWatchers.values()) {
watcher.removeAllListeners();
watcher.stop();
}
for (const orchestrator of this.activePlanOrchestrators.values()) {
orchestrator.cancel();
}
// Clear remaining Maps that accumulate session references
this.respawnTimers.clear();
this.runSummaryTrackers.clear();