mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: bump version to 0.1488
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -425,21 +425,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Tasks Panel -->
|
||||
<div class="team-tasks-panel" id="teamTasksPanel" style="display: none;">
|
||||
<div class="team-tasks-header">
|
||||
<span class="team-tasks-header-text">Team Tasks</span>
|
||||
<div class="team-tasks-progress">
|
||||
<div class="team-tasks-progress-bar">
|
||||
<div class="team-tasks-progress-fill"></div>
|
||||
</div>
|
||||
<span class="team-tasks-progress-text">0/0</span>
|
||||
</div>
|
||||
<button class="team-tasks-close" onclick="app.hideTeamTasksPanel()" title="Hide">─</button>
|
||||
</div>
|
||||
<div class="team-tasks-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Session Options Modal (includes Respawn Settings) -->
|
||||
<div class="modal" id="sessionOptionsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
|
||||
|
||||
@@ -5317,20 +5317,6 @@ kbd {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Teammate terminal pane (xterm.js embedded in subagent window) */
|
||||
.subagent-window-body.teammate-terminal-body {
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.subagent-window-body.teammate-terminal-body .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
/* Larger default size for terminal windows */
|
||||
.subagent-window.has-terminal {
|
||||
width: 550px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for subagent window body */
|
||||
.subagent-window-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -5499,187 +5485,6 @@ kbd {
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ========== Agent Teams (Teammate Badges + Team Tasks Panel) ========== */
|
||||
|
||||
/* Teammate badge in subagent panel/windows */
|
||||
.teammate-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.teammate-color-blue {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.teammate-color-green {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.teammate-color-yellow {
|
||||
background: #eab308;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Teammate dot icon replacing robot emoji */
|
||||
.teammate-dot {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.teammate-dot.teammate-color-blue { color: #3b82f6; }
|
||||
.teammate-dot.teammate-color-green { color: #22c55e; }
|
||||
.teammate-dot.teammate-color-yellow { color: #eab308; }
|
||||
|
||||
/* Subagent items that are teammates get a subtle left border */
|
||||
.subagent-item.is-teammate {
|
||||
border-left: 2px solid #3b82f6;
|
||||
}
|
||||
.subagent-item.is-teammate .teammate-color-green ~ * { border-left-color: #22c55e; }
|
||||
|
||||
/* Team Tasks Panel */
|
||||
.team-tasks-panel {
|
||||
position: fixed;
|
||||
width: 360px;
|
||||
min-width: 260px;
|
||||
min-height: 120px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 900;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
/* Resize handle indicator */
|
||||
.team-tasks-panel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: linear-gradient(135deg, transparent 50%, var(--border-light) 50%, var(--border-light) 60%, transparent 60%, transparent 70%, var(--border-light) 70%, var(--border-light) 80%, transparent 80%);
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.team-tasks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: move;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.team-tasks-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.team-tasks-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.team-tasks-header-text {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.team-tasks-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.team-tasks-progress-bar {
|
||||
width: 60px;
|
||||
height: 6px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.team-tasks-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--success);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.team-tasks-progress-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.team-tasks-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.team-task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.team-task-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.team-task-item.in-progress .team-task-status {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.team-task-status {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.team-task-subject {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team-task-owner {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.team-task-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ========== Project Insights Panel (Bash File Viewers) ========== */
|
||||
|
||||
.project-insights-panel {
|
||||
|
||||
@@ -140,14 +140,3 @@ export const SessionInputWithLimitSchema = z.object({
|
||||
input: z.string().max(100000), // 100KB max input
|
||||
useScreen: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ========== Teammate Pane Routes ==========
|
||||
|
||||
/**
|
||||
* Schema for POST /api/sessions/:id/teammate-pane-input
|
||||
* Sends keyboard input to a teammate's tmux pane.
|
||||
*/
|
||||
export const TeammatePaneInputSchema = z.object({
|
||||
paneTarget: z.string().regex(/^(%\d+|\d+)$/, 'Invalid pane target format'),
|
||||
input: z.string().max(100000),
|
||||
});
|
||||
|
||||
+13
-313
@@ -36,7 +36,6 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { createRequire } from 'node:module';
|
||||
import { RunSummaryTracker } from '../run-summary.js';
|
||||
import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js';
|
||||
import { TeamWatcher } from '../team-watcher.js';
|
||||
|
||||
// Load version from package.json
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -54,9 +53,6 @@ import {
|
||||
type PersistedRespawnConfig,
|
||||
type NiceConfig,
|
||||
type ImageDetectedEvent,
|
||||
type TeamConfig,
|
||||
type TeamTask,
|
||||
type InboxMessage,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
} from '../types.js';
|
||||
import {
|
||||
@@ -69,7 +65,6 @@ import {
|
||||
HookEventSchema,
|
||||
ConfigUpdateSchema,
|
||||
RespawnConfigSchema,
|
||||
TeammatePaneInputSchema,
|
||||
} from './schemas.js';
|
||||
import { StaleExpirationMap } from '../utils/index.js';
|
||||
|
||||
@@ -377,11 +372,6 @@ export class WebServer extends EventEmitter {
|
||||
detected: (event: ImageDetectedEvent) => void;
|
||||
error: (error: Error, sessionId?: string) => void;
|
||||
} | null = null;
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
// Pane streaming for teammate tmux panes: keyed by "sessionId:paneTarget"
|
||||
private paneStreams: Map<string, { tailProcess: ChildProcess; outputFile: string; muxName: string; paneTarget: string; sessionId: string; teammateName: string }> = new Map();
|
||||
private paneStreamTempDir: string | null = null;
|
||||
|
||||
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
|
||||
super();
|
||||
this.port = port;
|
||||
@@ -504,176 +494,6 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private setupTeamWatcherListeners(): void {
|
||||
this.teamWatcher.on('teamCreated', (team: TeamConfig) => {
|
||||
this.broadcast('team:created', team);
|
||||
this.startPaneStreamsForTeam(team);
|
||||
});
|
||||
this.teamWatcher.on('teamUpdated', (team: TeamConfig) => {
|
||||
this.broadcast('team:updated', team);
|
||||
this.startPaneStreamsForTeam(team);
|
||||
});
|
||||
this.teamWatcher.on('teamRemoved', (team: TeamConfig) => {
|
||||
this.broadcast('team:removed', team);
|
||||
this.stopPaneStreamsForSession(team.leadSessionId);
|
||||
});
|
||||
this.teamWatcher.on('taskUpdated', (data: { teamName: string; tasks: TeamTask[] }) => {
|
||||
// Filter out internal tasks for broadcast
|
||||
const visibleTasks = data.tasks.filter(t => !t.metadata?._internal);
|
||||
this.broadcast('team:task_updated', { teamName: data.teamName, tasks: visibleTasks });
|
||||
});
|
||||
this.teamWatcher.on('inboxMessage', (data: { teamName: string; member: string; message: InboxMessage }) => {
|
||||
this.broadcast('team:inbox_message', data);
|
||||
});
|
||||
}
|
||||
|
||||
/** Start pane streams for any tmux-pane teammates in a team */
|
||||
private startPaneStreamsForTeam(team: TeamConfig): void {
|
||||
if (this.mux.backend !== 'tmux') return;
|
||||
const tmuxMgr = this.mux as TmuxManager;
|
||||
|
||||
// Find the mux session for this team's lead
|
||||
const muxSession = tmuxMgr.getSession(team.leadSessionId);
|
||||
if (!muxSession) return;
|
||||
|
||||
// Enable mouse mode so users can click between panes in the main terminal
|
||||
tmuxMgr.enableMouseMode(muxSession.muxName);
|
||||
|
||||
let paneTeammates = this.teamWatcher.getTmuxPaneTeammates(team.name);
|
||||
|
||||
// Auto-discover panes if config.json doesn't include tmuxPaneId.
|
||||
// When Claude Code creates agent team split panes in tmux mode, the panes
|
||||
// exist in the tmux session but config.json may not include pane IDs.
|
||||
// We discover them by querying tmux and matching to non-lead teammates.
|
||||
if (paneTeammates.length === 0) {
|
||||
const nonLeadMembers = team.members.filter(m => m.agentType !== 'team-lead');
|
||||
if (nonLeadMembers.length === 0) return;
|
||||
|
||||
const panes = tmuxMgr.listPanes(muxSession.muxName);
|
||||
// Skip pane index 0 (leader's pane) — remaining panes are teammates
|
||||
const teammatePanes = panes.filter(p => p.paneIndex > 0).sort((a, b) => a.paneIndex - b.paneIndex);
|
||||
|
||||
if (teammatePanes.length > 0) {
|
||||
// Match discovered panes to teammates in order
|
||||
const discovered: Array<typeof team.members[0] & { tmuxPaneId: string }> = [];
|
||||
for (let i = 0; i < Math.min(teammatePanes.length, nonLeadMembers.length); i++) {
|
||||
const paneId = teammatePanes[i].paneId; // e.g., "%1"
|
||||
const member = nonLeadMembers[i];
|
||||
// Write back to member for future lookups
|
||||
member.tmuxPaneId = paneId;
|
||||
discovered.push({ ...member, tmuxPaneId: paneId });
|
||||
}
|
||||
paneTeammates = discovered;
|
||||
console.log(`[Server] Auto-discovered ${discovered.length} teammate panes via tmux list-panes`);
|
||||
}
|
||||
}
|
||||
|
||||
if (paneTeammates.length === 0) return;
|
||||
|
||||
// Ensure temp directory exists
|
||||
if (!this.paneStreamTempDir) {
|
||||
this.paneStreamTempDir = mkdtempSync(join(tmpdir(), 'claudeman-panes-'));
|
||||
}
|
||||
|
||||
for (const teammate of paneTeammates) {
|
||||
const streamKey = `${team.leadSessionId}:${teammate.tmuxPaneId}`;
|
||||
|
||||
// Skip if stream already exists
|
||||
if (this.paneStreams.has(streamKey)) continue;
|
||||
|
||||
const outputFile = join(this.paneStreamTempDir, `pane-${teammate.name}-${Date.now()}.raw`);
|
||||
|
||||
// Create the output file so tail -f doesn't error
|
||||
writeFileSync(outputFile, '');
|
||||
|
||||
// Start tmux pipe-pane to redirect pane output to the file
|
||||
const started = tmuxMgr.startPipePane(muxSession.muxName, teammate.tmuxPaneId, outputFile);
|
||||
if (!started) {
|
||||
console.error(`[Server] Failed to start pipe-pane for teammate ${teammate.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start tail -f to stream the output file contents
|
||||
const tailProcess = spawn('tail', ['-f', '-n', '0', outputFile], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
tailProcess.stdout?.on('data', (chunk: Buffer) => {
|
||||
this.broadcast('teammate:terminal', {
|
||||
sessionId: team.leadSessionId,
|
||||
teammateName: teammate.name,
|
||||
paneTarget: teammate.tmuxPaneId,
|
||||
data: chunk.toString('base64'),
|
||||
});
|
||||
});
|
||||
|
||||
tailProcess.on('error', (err) => {
|
||||
console.error(`[Server] Pane stream tail error for ${teammate.name}:`, err.message);
|
||||
});
|
||||
|
||||
this.paneStreams.set(streamKey, {
|
||||
tailProcess,
|
||||
outputFile,
|
||||
muxName: muxSession.muxName,
|
||||
paneTarget: teammate.tmuxPaneId,
|
||||
sessionId: team.leadSessionId,
|
||||
teammateName: teammate.name,
|
||||
});
|
||||
|
||||
// Notify frontend that a pane is available
|
||||
this.broadcast('teammate:pane_available', {
|
||||
sessionId: team.leadSessionId,
|
||||
teammateName: teammate.name,
|
||||
paneTarget: teammate.tmuxPaneId,
|
||||
color: teammate.color || 'blue',
|
||||
});
|
||||
|
||||
console.log(`[Server] Started pane stream for teammate ${teammate.name} (pane ${teammate.tmuxPaneId})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop all pane streams for a session */
|
||||
private stopPaneStreamsForSession(sessionId: string): void {
|
||||
if (this.mux.backend !== 'tmux') return;
|
||||
const tmuxMgr = this.mux as TmuxManager;
|
||||
|
||||
for (const [key, stream] of this.paneStreams) {
|
||||
if (stream.sessionId === sessionId) {
|
||||
// Kill the tail process
|
||||
try { stream.tailProcess.kill('SIGTERM'); } catch { /* already dead */ }
|
||||
// Stop tmux pipe-pane
|
||||
tmuxMgr.stopPipePane(stream.muxName, stream.paneTarget);
|
||||
// Remove output file
|
||||
try { unlinkSync(stream.outputFile); } catch { /* ignore */ }
|
||||
this.paneStreams.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop all pane streams and clean up temp directory */
|
||||
private cleanupAllPaneStreams(): void {
|
||||
if (this.mux.backend === 'tmux') {
|
||||
const tmuxMgr = this.mux as TmuxManager;
|
||||
for (const [, stream] of this.paneStreams) {
|
||||
try { stream.tailProcess.kill('SIGTERM'); } catch { /* already dead */ }
|
||||
tmuxMgr.stopPipePane(stream.muxName, stream.paneTarget);
|
||||
try { unlinkSync(stream.outputFile); } catch { /* ignore */ }
|
||||
}
|
||||
} else {
|
||||
for (const [, stream] of this.paneStreams) {
|
||||
try { stream.tailProcess.kill('SIGTERM'); } catch { /* already dead */ }
|
||||
try { unlinkSync(stream.outputFile); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
this.paneStreams.clear();
|
||||
|
||||
// Remove temp directory
|
||||
if (this.paneStreamTempDir) {
|
||||
try { rmSync(this.paneStreamTempDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
this.paneStreamTempDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async setupRoutes(): Promise<void> {
|
||||
// Serve static files
|
||||
await this.app.register(fastifyStatic, {
|
||||
@@ -697,18 +517,6 @@ export class WebServer extends EventEmitter {
|
||||
// Buffers are fetched on-demand when switching tabs
|
||||
this.sendSSE(reply, 'init', this.getLightState());
|
||||
|
||||
// Send pane_available events for any active pane streams
|
||||
for (const stream of this.paneStreams.values()) {
|
||||
const team = this.teamWatcher?.getTeamForSession(stream.sessionId);
|
||||
const member = team?.members.find(m => m.name === stream.teammateName);
|
||||
this.sendSSE(reply, 'teammate:pane_available', {
|
||||
sessionId: stream.sessionId,
|
||||
teammateName: stream.teammateName,
|
||||
paneTarget: stream.paneTarget,
|
||||
color: member?.color || 'blue',
|
||||
});
|
||||
}
|
||||
|
||||
req.raw.on('close', () => {
|
||||
this.sseClients.delete(reply);
|
||||
});
|
||||
@@ -843,7 +651,7 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
|
||||
// Session management
|
||||
this.app.get('/api/sessions', async () => this.getSessionsState());
|
||||
this.app.get('/api/sessions', async () => this.getLightSessionsState());
|
||||
|
||||
this.app.post('/api/sessions', async (req): Promise<SessionResponse> => {
|
||||
// Prevent unbounded session creation
|
||||
@@ -3503,106 +3311,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
});
|
||||
|
||||
|
||||
// ========== Agent Teams ==========
|
||||
|
||||
// List all active teams
|
||||
this.app.get('/api/teams', async () => {
|
||||
const teams = this.teamWatcher.getTeams();
|
||||
// Enrich with session association
|
||||
const enriched = teams.map(team => ({
|
||||
...team,
|
||||
sessionExists: this.sessions.has(team.leadSessionId),
|
||||
activeTasks: this.teamWatcher.getActiveTaskCount(team.name),
|
||||
teammateCount: team.members.filter(m => m.agentType !== 'team-lead').length,
|
||||
}));
|
||||
return { success: true, data: enriched };
|
||||
});
|
||||
|
||||
// Get team for a specific session
|
||||
this.app.get('/api/sessions/:id/team', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const team = this.teamWatcher.getTeamForSession(id);
|
||||
if (!team) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...team,
|
||||
activeTasks: this.teamWatcher.getActiveTaskCount(team.name),
|
||||
teammateCount: team.members.filter(m => m.agentType !== 'team-lead').length,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Get team tasks for a session's team
|
||||
this.app.get('/api/sessions/:id/team-tasks', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const team = this.teamWatcher.getTeamForSession(id);
|
||||
if (!team) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const tasks = this.teamWatcher.getTeamTasks(team.name);
|
||||
return { success: true, data: tasks };
|
||||
});
|
||||
|
||||
// ========== Teammate Pane Endpoints ==========
|
||||
|
||||
// List teammate panes for a session
|
||||
this.app.get('/api/sessions/:id/teammate-panes', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const team = this.teamWatcher.getTeamForSession(id);
|
||||
if (!team) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const paneTeammates = this.teamWatcher.getTmuxPaneTeammates(team.name);
|
||||
const panes = paneTeammates.map(t => ({
|
||||
teammateName: t.name,
|
||||
paneTarget: t.tmuxPaneId,
|
||||
color: t.color || 'blue',
|
||||
hasStream: this.paneStreams.has(`${id}:${t.tmuxPaneId}`),
|
||||
}));
|
||||
return { success: true, data: panes };
|
||||
});
|
||||
|
||||
// Get pane buffer for a teammate
|
||||
this.app.get('/api/sessions/:id/teammate-pane-buffer/:paneTarget', async (req) => {
|
||||
const { id, paneTarget } = req.params as { id: string; paneTarget: string };
|
||||
if (this.mux.backend !== 'tmux') {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Pane access requires tmux backend');
|
||||
}
|
||||
const tmuxMgr = this.mux as TmuxManager;
|
||||
const muxSession = tmuxMgr.getSession(id);
|
||||
if (!muxSession) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const buffer = tmuxMgr.capturePaneBuffer(muxSession.muxName, paneTarget);
|
||||
if (buffer === null) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to capture pane buffer');
|
||||
}
|
||||
return { success: true, data: { buffer } };
|
||||
});
|
||||
|
||||
// Send input to a teammate's pane
|
||||
this.app.post('/api/sessions/:id/teammate-pane-input', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = TeammatePaneInputSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { paneTarget, input } = result.data;
|
||||
if (this.mux.backend !== 'tmux') {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Pane access requires tmux backend');
|
||||
}
|
||||
const tmuxMgr = this.mux as TmuxManager;
|
||||
const muxSession = tmuxMgr.getSession(id);
|
||||
if (!muxSession) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const success = tmuxMgr.sendInputToPane(muxSession.muxName, paneTarget, input);
|
||||
return { success };
|
||||
});
|
||||
|
||||
// ========== Hook Events ==========
|
||||
|
||||
this.app.post('/api/hook-event', async (req) => {
|
||||
@@ -3829,14 +3537,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any agent team associated with this session
|
||||
// Stop pane streams and notify frontend so team tasks/windows are removed
|
||||
this.stopPaneStreamsForSession(sessionId);
|
||||
const team = this.teamWatcher.getTeamForSession(sessionId);
|
||||
if (team) {
|
||||
this.broadcast('team:removed', team);
|
||||
}
|
||||
|
||||
// Stop and remove respawn controller - but save config first for restart recovery
|
||||
const controller = this.respawnControllers.get(sessionId);
|
||||
if (controller) {
|
||||
@@ -4227,9 +3927,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||||
// Inject team watcher for team-aware idle detection
|
||||
controller.setTeamWatcher(this.teamWatcher);
|
||||
|
||||
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
||||
const getTracker = () => this.runSummaryTrackers.get(sessionId);
|
||||
|
||||
@@ -5037,10 +4734,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
console.log('Image watcher disabled by user settings');
|
||||
}
|
||||
|
||||
// Start team watcher for Agent Teams support
|
||||
this.setupTeamWatcherListeners();
|
||||
this.teamWatcher.start();
|
||||
console.log('Team watcher started - monitoring ~/.claude/teams for agent team activity');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5336,14 +5029,21 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Stop image watcher
|
||||
imageWatcher.stop();
|
||||
|
||||
// Stop team watcher and clean up pane streams
|
||||
this.cleanupAllPaneStreams();
|
||||
this.teamWatcher.removeAllListeners();
|
||||
this.teamWatcher.stop();
|
||||
|
||||
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
|
||||
fileStreamManager.destroy();
|
||||
|
||||
// Stop all remaining tracked resources before clearing their Maps
|
||||
for (const tracker of this.runSummaryTrackers.values()) {
|
||||
tracker.stop();
|
||||
}
|
||||
for (const watcher of this.transcriptWatchers.values()) {
|
||||
watcher.removeAllListeners();
|
||||
watcher.stop();
|
||||
}
|
||||
for (const orchestrator of this.activePlanOrchestrators.values()) {
|
||||
orchestrator.cancel();
|
||||
}
|
||||
|
||||
// Clear remaining Maps that accumulate session references
|
||||
this.respawnTimers.clear();
|
||||
this.runSummaryTrackers.clear();
|
||||
|
||||
Reference in New Issue
Block a user