mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +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
|
## 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
|
1. Check: `echo $CLAUDEMAN_TMUX` - 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
|
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
|
3. Use the web UI or `./scripts/tmux-manager.sh` instead of direct kill commands
|
||||||
|
|
||||||
## COM Shorthand (Deployment)
|
## 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`)
|
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`
|
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
|
## 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).
|
**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
|
## 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`
|
- **`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
|
- **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 tmux sessions blindly** — Check `$CLAUDEMAN_TMUX` first; you might be inside one
|
||||||
- **Don't kill screens blindly** — Check `$CLAUDEMAN_SCREEN` first; you might be inside one
|
|
||||||
- **Port 3000 during E2E** — Tests use ports 3183-3193; don't run dev server on 3000 while testing
|
- **Port 3000 during E2E** — Tests use ports 3183-3193; don't run dev server on 3000 while testing
|
||||||
|
|
||||||
## Import Conventions
|
## Import Conventions
|
||||||
@@ -90,9 +89,9 @@ journalctl --user -u claudeman-web -f
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `src/session.ts` | PTY wrapper: `runPrompt()`, `startInteractive()`, `startShell()` |
|
| `src/session.ts` | PTY wrapper: `runPrompt()`, `startInteractive()`, `startShell()` |
|
||||||
| `src/mux-interface.ts` | `TerminalMultiplexer` interface + `MuxSession` type |
|
| `src/mux-interface.ts` | `TerminalMultiplexer` interface + `MuxSession` type |
|
||||||
| `src/mux-factory.ts` | Auto-detect tmux/screen, create multiplexer (`CLAUDEMAN_MUX` override) |
|
| `src/mux-factory.ts` | Create tmux multiplexer (`CLAUDEMAN_MUX` override for legacy screen) |
|
||||||
| `src/tmux-manager.ts` | tmux session management (preferred backend) |
|
| `src/tmux-manager.ts` | tmux session management |
|
||||||
| `src/screen-manager.ts` | GNU screen persistence, ghost discovery (deprecated fallback) |
|
| `src/screen-manager.ts` | GNU screen fallback (deprecated) |
|
||||||
| `src/session-manager.ts` | Session lifecycle, cleanup |
|
| `src/session-manager.ts` | Session lifecycle, cleanup |
|
||||||
| `src/state-store.ts` | State persistence to `~/.claudeman/state.json` |
|
| `src/state-store.ts` | State persistence to `~/.claudeman/state.json` |
|
||||||
| `src/respawn-controller.ts` | State machine for autonomous cycling |
|
| `src/respawn-controller.ts` | State machine for autonomous cycling |
|
||||||
@@ -155,9 +154,9 @@ journalctl --user -u claudeman-web -f
|
|||||||
|
|
||||||
### Key Patterns
|
### 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`.
|
**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/state.json` | Sessions, settings, tokens, respawn config |
|
||||||
| `~/.claudeman/mux-sessions.json` | Tmux session metadata for recovery |
|
| `~/.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 |
|
| `~/.claudeman/settings.json` | User preferences |
|
||||||
|
|
||||||
## Default Settings
|
## Default Settings
|
||||||
@@ -193,20 +191,13 @@ UI defaults are set in `src/web/public/app.js` using `??` fallbacks. To change d
|
|||||||
|
|
||||||
## Testing
|
## 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:
|
**Respawn tests**: Use MockSession from `test/respawn-test-utils.ts` to avoid spawning real Claude processes.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Debugging
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| State not persisting | `cat ~/.claudeman/state.json` | Check file permissions, disk space |
|
||||||
|
|
||||||
## Performance Constraints
|
## Performance Constraints
|
||||||
@@ -296,7 +287,7 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base
|
|||||||
| **Test utilities** | `test/respawn-test-utils.ts` |
|
| **Test utilities** | `test/respawn-test-utils.ts` |
|
||||||
| **Memory leak patterns** | `test/memory-leak-prevention.test.ts` |
|
| **Memory leak patterns** | `test/memory-leak-prevention.test.ts` |
|
||||||
| **Keyboard shortcuts** | README.md or App Settings in web UI |
|
| **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 |
|
| **Plan orchestrator** | `src/plan-orchestrator.ts` file header |
|
||||||
| **Agent prompts** | `src/prompts/` directory |
|
| **Agent prompts** | `src/prompts/` directory |
|
||||||
| **Agent Teams (experimental)** | `agent-teams/README.md`, `agent-teams/design.md` |
|
| **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-manager.sh` | Safe tmux session management (use instead of direct kill commands) |
|
||||||
| `scripts/tmux-chooser.sh` | Mobile-friendly tmux session picker (`sc` alias) |
|
| `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/monitor-respawn.sh` | Monitor respawn state machine in real-time |
|
||||||
| `scripts/postinstall.js` | npm postinstall hook for setup |
|
| `scripts/postinstall.js` | npm postinstall hook for setup |
|
||||||
| `scripts/data-generator.sh` | Generate test data for development |
|
| `scripts/data-generator.sh` | Generate test data for development |
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"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",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+1
-14
@@ -149,25 +149,12 @@ export class AiIdleChecker extends AiCheckerBase<
|
|||||||
protected readonly logPrefix = '[AiIdleChecker]';
|
protected readonly logPrefix = '[AiIdleChecker]';
|
||||||
protected readonly checkDescription = 'AI idle check';
|
protected readonly checkDescription = 'AI idle check';
|
||||||
|
|
||||||
/** Number of active teammates (injected by respawn controller) */
|
|
||||||
private teammateCount: number = 0;
|
|
||||||
|
|
||||||
constructor(sessionId: string, config: Partial<AiIdleCheckConfig> = {}) {
|
constructor(sessionId: string, config: Partial<AiIdleCheckConfig> = {}) {
|
||||||
super(sessionId, DEFAULT_AI_CHECK_CONFIG, config);
|
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 {
|
protected buildPrompt(terminalBuffer: string): string {
|
||||||
let prompt = AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer);
|
return AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer);
|
||||||
if (this.teammateCount > 0) {
|
|
||||||
const teamContext = `\n\n## Agent Teams Context\n${this.teammateCount} agent teammate(s) are currently working on tasks for this session.\nIf teammates are active, answer WORKING — the lead is waiting for their results.\n`;
|
|
||||||
prompt = prompt.replace('## Terminal Output to Analyze', teamContext + '## Terminal Output to Analyze');
|
|
||||||
}
|
|
||||||
return prompt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseVerdict(output: string): { verdict: AiCheckVerdict; reasoning: string } | null {
|
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 {
|
processTerminalData(data: string): void {
|
||||||
if (!this._enabled || this._destroyed) return;
|
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;
|
this._lineBuffer += data;
|
||||||
|
|
||||||
// Prevent unbounded growth
|
// 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 ==========
|
// ========== 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 {
|
private processLine(line: string): void {
|
||||||
// Strip ANSI codes for cleaner pattern matching
|
|
||||||
const cleanLine = this.stripAnsi(line);
|
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
|
// Check for tool start
|
||||||
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
|
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
|
||||||
|
|||||||
+15
-2
@@ -10,15 +10,28 @@
|
|||||||
|
|
||||||
import { program } from './cli.js';
|
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
|
// Handle uncaught errors
|
||||||
process.on('uncaughtException', (err) => {
|
process.on('uncaughtException', (err) => {
|
||||||
console.error('Uncaught exception:', err.message);
|
console.error('Uncaught exception:', err.message);
|
||||||
process.exit(1);
|
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) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
console.error('Unhandled rejection:', reason);
|
console.error('Unhandled rejection:', reason);
|
||||||
process.exit(1);
|
if (isWebMode) {
|
||||||
|
console.error('[RECOVERED] Server continuing after unhandled rejection');
|
||||||
|
} else {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run CLI
|
// Run CLI
|
||||||
|
|||||||
@@ -1317,6 +1317,14 @@ export class RalphTracker extends EventEmitter {
|
|||||||
processTerminalData(data: string): void {
|
processTerminalData(data: string): void {
|
||||||
// Remove ANSI escape codes for cleaner parsing
|
// Remove ANSI escape codes for cleaner parsing
|
||||||
const cleanData = data.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
|
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 tracker is disabled, only check for patterns that should auto-enable it
|
||||||
if (!this._loopState.enabled) {
|
if (!this._loopState.enabled) {
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ import type {
|
|||||||
CycleOutcome,
|
CycleOutcome,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { TeamWatcher } from './team-watcher.js';
|
|
||||||
|
|
||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
|
|
||||||
@@ -639,9 +638,6 @@ export class RespawnController extends EventEmitter {
|
|||||||
/** The session being controlled */
|
/** The session being controlled */
|
||||||
private session: Session;
|
private session: Session;
|
||||||
|
|
||||||
/** Optional team watcher for team-aware idle detection */
|
|
||||||
private teamWatcher: TeamWatcher | null = null;
|
|
||||||
|
|
||||||
/** Current configuration */
|
/** Current configuration */
|
||||||
private config: RespawnConfig;
|
private config: RespawnConfig;
|
||||||
|
|
||||||
@@ -1235,10 +1231,6 @@ export class RespawnController extends EventEmitter {
|
|||||||
*
|
*
|
||||||
* @fires stateChanged - Transitions to 'watching'
|
* @fires stateChanged - Transitions to 'watching'
|
||||||
*/
|
*/
|
||||||
setTeamWatcher(watcher: TeamWatcher | null): void {
|
|
||||||
this.teamWatcher = watcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (!this.config.enabled) {
|
if (!this.config.enabled) {
|
||||||
this.log('Respawn is disabled');
|
this.log('Respawn is disabled');
|
||||||
@@ -2191,12 +2183,6 @@ export class RespawnController extends EventEmitter {
|
|||||||
// Get the terminal buffer for analysis
|
// Get the terminal buffer for analysis
|
||||||
const buffer = this.terminalBuffer.value;
|
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) => {
|
this.aiChecker.check(buffer).then((result) => {
|
||||||
// If state changed while checking (e.g., cancelled), ignore result
|
// If state changed while checking (e.g., cancelled), ignore result
|
||||||
if (this._state !== 'ai_checking') {
|
if (this._state !== 'ai_checking') {
|
||||||
@@ -2750,17 +2736,6 @@ export class RespawnController extends EventEmitter {
|
|||||||
return;
|
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}`);
|
this.log(`Idle confirmed via: ${reason}`);
|
||||||
const status = this.getDetectionStatus();
|
const status = this.getDetectionStatus();
|
||||||
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
|
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
|
||||||
|
|||||||
+11
-4
@@ -430,6 +430,13 @@ export class Session extends EventEmitter {
|
|||||||
niceConfig?: NiceConfig; // Nice prioritying configuration
|
niceConfig?: NiceConfig; // Nice prioritying configuration
|
||||||
}) {
|
}) {
|
||||||
super();
|
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.id = config.id || uuidv4();
|
||||||
this.workingDir = config.workingDir;
|
this.workingDir = config.workingDir;
|
||||||
this.createdAt = config.createdAt || Date.now();
|
this.createdAt = config.createdAt || Date.now();
|
||||||
@@ -1016,11 +1023,11 @@ export class Session extends EventEmitter {
|
|||||||
this.emit('terminal', data);
|
this.emit('terminal', data);
|
||||||
this.emit('output', data);
|
this.emit('output', data);
|
||||||
|
|
||||||
// Forward to Ralph tracker to detect Ralph loops and todos (handles own stripping)
|
// Forward to Ralph tracker to detect Ralph loops and todos (pre-stripped)
|
||||||
this._ralphTracker.processTerminalData(data);
|
this._ralphTracker.processCleanData(cleanData);
|
||||||
|
|
||||||
// Forward to Bash tool parser to detect file-viewing commands (handles own stripping)
|
// Forward to Bash tool parser to detect file-viewing commands (pre-stripped)
|
||||||
this._bashToolParser.processTerminalData(data);
|
this._bashToolParser.processCleanData(cleanData);
|
||||||
|
|
||||||
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
|
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
|
||||||
this.parseTokensFromStatusLine(cleanData);
|
this.parseTokensFromStatusLine(cleanData);
|
||||||
|
|||||||
+90
-28
@@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
|
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 { homedir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js';
|
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js';
|
||||||
@@ -105,6 +106,7 @@ export class StateStore {
|
|||||||
/**
|
/**
|
||||||
* Schedules a debounced save.
|
* Schedules a debounced save.
|
||||||
* Multiple calls within 500ms are batched into a single disk write.
|
* Multiple calls within 500ms are batched into a single disk write.
|
||||||
|
* Uses async I/O to avoid blocking the event loop.
|
||||||
*/
|
*/
|
||||||
save(): void {
|
save(): void {
|
||||||
this.dirty = true;
|
this.dirty = true;
|
||||||
@@ -112,17 +114,18 @@ export class StateStore {
|
|||||||
return; // Already scheduled
|
return; // Already scheduled
|
||||||
}
|
}
|
||||||
this.saveTimeout = setTimeout(() => {
|
this.saveTimeout = setTimeout(() => {
|
||||||
this.saveNow();
|
this.saveNowAsync().catch((err) => {
|
||||||
|
console.error('[StateStore] Async save failed:', err);
|
||||||
|
});
|
||||||
}, SAVE_DEBOUNCE_MS);
|
}, SAVE_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immediately writes state to disk using atomic write pattern.
|
* Async version of saveNow — used by the debounced save() path.
|
||||||
* Writes to temp file first, then renames to prevent corruption on crash.
|
* Uses non-blocking fs.promises to avoid blocking the event loop during
|
||||||
* Includes backup mechanism and circuit breaker for reliability.
|
* the debounced write cycle. For synchronous shutdown flush, use saveNow().
|
||||||
* Use when guaranteed persistence is required (e.g., before shutdown).
|
|
||||||
*/
|
*/
|
||||||
saveNow(): void {
|
async saveNowAsync(): Promise<void> {
|
||||||
if (this.saveTimeout) {
|
if (this.saveTimeout) {
|
||||||
clearTimeout(this.saveTimeout);
|
clearTimeout(this.saveTimeout);
|
||||||
this.saveTimeout = null;
|
this.saveTimeout = null;
|
||||||
@@ -146,7 +149,7 @@ export class StateStore {
|
|||||||
|
|
||||||
// Step 1: Serialize state (validates it's JSON-safe)
|
// Step 1: Serialize state (validates it's JSON-safe)
|
||||||
try {
|
try {
|
||||||
json = JSON.stringify(this.state, null, 2);
|
json = JSON.stringify(this.state);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||||
this.consecutiveSaveFailures++;
|
this.consecutiveSaveFailures++;
|
||||||
@@ -154,29 +157,22 @@ export class StateStore {
|
|||||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||||
this.circuitBreakerOpen = true;
|
this.circuitBreakerOpen = true;
|
||||||
}
|
}
|
||||||
// Don't throw - this prevents crashing the app
|
|
||||||
// Mark dirty again so we can retry later
|
|
||||||
this.dirty = true;
|
this.dirty = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Create backup of current state file (if exists)
|
// Step 2: Create backup via file copy (async, no read+parse+write)
|
||||||
try {
|
try {
|
||||||
if (existsSync(this.filePath)) {
|
await access(this.filePath);
|
||||||
// Read current file and verify it's valid JSON before backing up
|
await copyFile(this.filePath, backupPath);
|
||||||
const currentContent = readFileSync(this.filePath, 'utf-8');
|
} catch {
|
||||||
JSON.parse(currentContent); // Validate
|
// Backup failed or file doesn't exist yet - continue with write
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Atomic write: write to temp file, then rename
|
// Step 3: Atomic write: write to temp file, then rename (async)
|
||||||
try {
|
try {
|
||||||
writeFileSync(tempPath, json, 'utf-8');
|
await writeFile(tempPath, json, 'utf-8');
|
||||||
renameSync(tempPath, this.filePath);
|
await rename(tempPath, this.filePath);
|
||||||
|
|
||||||
// Success! Reset failure counter
|
// Success! Reset failure counter
|
||||||
this.consecutiveSaveFailures = 0;
|
this.consecutiveSaveFailures = 0;
|
||||||
@@ -190,11 +186,9 @@ export class StateStore {
|
|||||||
|
|
||||||
// Try to clean up temp file on error
|
// Try to clean up temp file on error
|
||||||
try {
|
try {
|
||||||
if (existsSync(tempPath)) {
|
await unlink(tempPath);
|
||||||
unlinkSync(tempPath);
|
} catch {
|
||||||
}
|
// Temp file may not exist
|
||||||
} catch (cleanupErr) {
|
|
||||||
console.warn('[StateStore] Failed to cleanup temp file during save error:', cleanupErr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check circuit breaker threshold
|
// Check circuit breaker threshold
|
||||||
@@ -203,7 +197,75 @@ export class StateStore {
|
|||||||
this.circuitBreakerOpen = true;
|
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;
|
this.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-16
@@ -11,7 +11,8 @@ import { createReadStream } from 'node:fs';
|
|||||||
import { createInterface } from 'node:readline';
|
import { createInterface } from 'node:readline';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join, basename } from 'node:path';
|
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';
|
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS } from './config/map-limits.js';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
@@ -208,11 +209,11 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
this._isRunning = true;
|
this._isRunning = true;
|
||||||
|
|
||||||
// Initial scan
|
// Initial scan
|
||||||
this.scanForSubagents();
|
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
|
||||||
|
|
||||||
// Periodic scan for new subagent directories
|
// Periodic scan for new subagent directories
|
||||||
this.pollInterval = setInterval(() => {
|
this.pollInterval = setInterval(() => {
|
||||||
this.scanForSubagents();
|
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
|
||||||
}, POLL_INTERVAL_MS);
|
}, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Periodic liveness check for active subagents
|
// Periodic liveness check for active subagents
|
||||||
@@ -583,8 +584,13 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private async findSubagentProcess(sessionId: string): Promise<number | null> {
|
private async findSubagentProcess(sessionId: string): Promise<number | null> {
|
||||||
try {
|
try {
|
||||||
// Find all claude processes
|
// Find all claude processes (async to avoid blocking event loop)
|
||||||
const pgrepOutput = execSync('pgrep -f "claude"', { encoding: 'utf8' });
|
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);
|
const pids = pgrepOutput.trim().split('\n').filter(Boolean);
|
||||||
|
|
||||||
for (const pidStr of pids) {
|
for (const pidStr of pids) {
|
||||||
@@ -592,8 +598,8 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
if (Number.isNaN(pid)) continue;
|
if (Number.isNaN(pid)) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check /proc/{pid}/environ for session ID
|
// Check /proc/{pid}/environ for session ID (async read)
|
||||||
const environ = readFileSync(`/proc/${pid}/environ`, 'utf8');
|
const environ = await readFile(`/proc/${pid}/environ`, 'utf8');
|
||||||
if (environ.includes(sessionId)) {
|
if (environ.includes(sessionId)) {
|
||||||
return pid;
|
return pid;
|
||||||
}
|
}
|
||||||
@@ -602,8 +608,8 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Also check /proc/{pid}/cmdline for session ID
|
// Also check /proc/{pid}/cmdline for session ID (async read)
|
||||||
const cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8');
|
||||||
if (cmdline.includes(sessionId)) {
|
if (cmdline.includes(sessionId)) {
|
||||||
return pid;
|
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;
|
if (!existsSync(CLAUDE_PROJECTS_DIR)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projects = readdirSync(CLAUDE_PROJECTS_DIR);
|
const projects = await readdir(CLAUDE_PROJECTS_DIR);
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
const projectPath = join(CLAUDE_PROJECTS_DIR, project);
|
const projectPath = join(CLAUDE_PROJECTS_DIR, project);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = statSync(projectPath);
|
const st = await statAsync(projectPath);
|
||||||
if (!stat.isDirectory()) continue;
|
if (!st.isDirectory()) continue;
|
||||||
|
|
||||||
const sessions = readdirSync(projectPath);
|
const sessions = await readdir(projectPath);
|
||||||
|
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const sessionPath = join(projectPath, session);
|
const sessionPath = join(projectPath, session);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionStat = statSync(sessionPath);
|
const sessionStat = await statAsync(sessionPath);
|
||||||
if (!sessionStat.isDirectory()) continue;
|
if (!sessionStat.isDirectory()) continue;
|
||||||
|
|
||||||
const subagentDir = join(sessionPath, 'subagents');
|
const subagentDir = join(sessionPath, 'subagents');
|
||||||
|
|||||||
@@ -1533,72 +1533,6 @@ export interface PaneInfo {
|
|||||||
height: number;
|
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 ==========
|
// ========== Plan Orchestrator Re-exports ==========
|
||||||
|
|
||||||
export type { PlanItem } from './plan-orchestrator.js';
|
export type { PlanItem } from './plan-orchestrator.js';
|
||||||
|
|||||||
+84
-144
@@ -1145,14 +1145,6 @@ class ClaudemanApp {
|
|||||||
// Once set, never recalculated. Persisted to localStorage and server.
|
// Once set, never recalculated. Persisted to localStorage and server.
|
||||||
this.subagentParentMap = new Map();
|
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.ralphStatePanelCollapsed = true; // Default to collapsed
|
||||||
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
|
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.closeSessionImagePopups(data.id); // Close image popup windows for this session
|
||||||
this.closeSessionSubagentWindows(data.id, true); // Close subagent windows and cleanup activity data
|
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
|
// Clean up idle timer for this session
|
||||||
const idleTimer = this.idleTimers.get(data.id);
|
const idleTimer = this.idleTimers.get(data.id);
|
||||||
if (idleTimer) {
|
if (idleTimer) {
|
||||||
@@ -2892,81 +2868,6 @@ class ClaudemanApp {
|
|||||||
}, 5 * 60 * 1000); // 5 minutes
|
}, 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) ==========
|
// ========== Image Detection Events (Screenshots & Generated Images) ==========
|
||||||
|
|
||||||
addListener('image:detected', (e) => {
|
addListener('image:detected', (e) => {
|
||||||
@@ -11285,6 +11186,16 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderSubagentPanel() {
|
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');
|
const list = this.$('subagentList');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
@@ -11777,6 +11688,17 @@ class ClaudemanApp {
|
|||||||
// This map stores agentId -> sessionId, where sessionId is the tab's data-id.
|
// This map stores agentId -> sessionId, where sessionId is the tab's data-id.
|
||||||
|
|
||||||
updateConnectionLines() {
|
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');
|
const svg = document.getElementById('connectionLines');
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
|
|
||||||
@@ -12007,13 +11929,6 @@ class ClaudemanApp {
|
|||||||
// ========== Subagent Floating Windows ==========
|
// ========== Subagent Floating Windows ==========
|
||||||
|
|
||||||
openSubagentWindow(agentId) {
|
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 window already exists, focus it
|
||||||
if (this.subagentWindows.has(agentId)) {
|
if (this.subagentWindows.has(agentId)) {
|
||||||
const existing = this.subagentWindows.get(agentId);
|
const existing = this.subagentWindows.get(agentId);
|
||||||
@@ -12581,49 +12496,74 @@ class ClaudemanApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = activity.slice(-100).map(a => {
|
// Incremental rendering: track how many items are already rendered
|
||||||
const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false });
|
const renderedCount = body.dataset.renderedCount ? parseInt(body.dataset.renderedCount, 10) : 0;
|
||||||
if (a.type === 'tool') {
|
const maxItems = 100;
|
||||||
return `<div class="activity-line">
|
const visibleActivity = activity.slice(-maxItems);
|
||||||
<span class="time">${time}</span>
|
|
||||||
<span class="tool-icon">${this.getToolIcon(a.tool)}</span>
|
// If activity was trimmed or this is a fresh render, do full rebuild
|
||||||
<span class="tool-name">${a.tool}</span>
|
if (renderedCount === 0 || renderedCount > visibleActivity.length || body.children.length === 0 ||
|
||||||
<span class="tool-detail">${this.escapeHtml(this.getToolDetail(a.tool, a.input))}</span>
|
(body.children.length === 1 && body.querySelector('.subagent-empty'))) {
|
||||||
</div>`;
|
// Full rebuild
|
||||||
} else if (a.type === 'tool_result') {
|
const html = visibleActivity.map(a => this._renderActivityItem(a)).join('');
|
||||||
const icon = a.isError ? '❌' : '📄';
|
body.innerHTML = html;
|
||||||
const statusClass = a.isError ? ' error' : '';
|
body.dataset.renderedCount = String(visibleActivity.length);
|
||||||
const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : '';
|
} else {
|
||||||
const preview = a.preview.length > 60 ? a.preview.substring(0, 60) + '...' : a.preview;
|
// Incremental: only append new items
|
||||||
return `<div class="activity-line result-line${statusClass}">
|
const newItems = visibleActivity.slice(renderedCount);
|
||||||
<span class="time">${time}</span>
|
if (newItems.length > 0) {
|
||||||
<span class="tool-icon">${icon}</span>
|
const newHtml = newItems.map(a => this._renderActivityItem(a)).join('');
|
||||||
<span class="tool-name">${a.tool || '→'}</span>
|
body.insertAdjacentHTML('beforeend', newHtml);
|
||||||
<span class="tool-detail">${this.escapeHtml(preview)}${sizeInfo}</span>
|
body.dataset.renderedCount = String(visibleActivity.length);
|
||||||
</div>`;
|
|
||||||
} else if (a.type === 'progress') {
|
// Trim excess children from the front if over maxItems
|
||||||
// Check for hook events
|
while (body.children.length > maxItems) {
|
||||||
const isHook = a.hookEvent || a.hookName;
|
body.removeChild(body.firstChild);
|
||||||
const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓');
|
}
|
||||||
const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType);
|
|
||||||
return `<div class="activity-line progress-line${isHook ? ' hook-line' : ''}">
|
|
||||||
<span class="time">${time}</span>
|
|
||||||
<span class="tool-icon">${icon}</span>
|
|
||||||
<span class="tool-detail">${this.escapeHtml(displayText)}</span>
|
|
||||||
</div>`;
|
|
||||||
} else if (a.type === 'message') {
|
|
||||||
const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text;
|
|
||||||
return `<div class="message-line">
|
|
||||||
<span class="time">${time}</span> 💬 ${this.escapeHtml(preview)}
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
return '';
|
}
|
||||||
}).join('');
|
|
||||||
|
|
||||||
body.innerHTML = html;
|
|
||||||
body.scrollTop = body.scrollHeight;
|
body.scrollTop = body.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderActivityItem(a) {
|
||||||
|
const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false });
|
||||||
|
if (a.type === 'tool') {
|
||||||
|
return `<div class="activity-line">
|
||||||
|
<span class="time">${time}</span>
|
||||||
|
<span class="tool-icon">${this.getToolIcon(a.tool)}</span>
|
||||||
|
<span class="tool-name">${a.tool}</span>
|
||||||
|
<span class="tool-detail">${this.escapeHtml(this.getToolDetail(a.tool, a.input))}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (a.type === 'tool_result') {
|
||||||
|
const icon = a.isError ? '❌' : '📄';
|
||||||
|
const statusClass = a.isError ? ' error' : '';
|
||||||
|
const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : '';
|
||||||
|
const preview = a.preview.length > 60 ? a.preview.substring(0, 60) + '...' : a.preview;
|
||||||
|
return `<div class="activity-line result-line${statusClass}">
|
||||||
|
<span class="time">${time}</span>
|
||||||
|
<span class="tool-icon">${icon}</span>
|
||||||
|
<span class="tool-name">${a.tool || '→'}</span>
|
||||||
|
<span class="tool-detail">${this.escapeHtml(preview)}${sizeInfo}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (a.type === 'progress') {
|
||||||
|
const isHook = a.hookEvent || a.hookName;
|
||||||
|
const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓');
|
||||||
|
const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType);
|
||||||
|
return `<div class="activity-line progress-line${isHook ? ' hook-line' : ''}">
|
||||||
|
<span class="time">${time}</span>
|
||||||
|
<span class="tool-icon">${icon}</span>
|
||||||
|
<span class="tool-detail">${this.escapeHtml(displayText)}</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (a.type === 'message') {
|
||||||
|
const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text;
|
||||||
|
return `<div class="message-line">
|
||||||
|
<span class="time">${time}</span> 💬 ${this.escapeHtml(preview)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// Update all open subagent windows
|
// Update all open subagent windows
|
||||||
updateSubagentWindows() {
|
updateSubagentWindows() {
|
||||||
for (const agentId of this.subagentWindows.keys()) {
|
for (const agentId of this.subagentWindows.keys()) {
|
||||||
|
|||||||
@@ -425,21 +425,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Session Options Modal (includes Respawn Settings) -->
|
||||||
<div class="modal" id="sessionOptionsModal">
|
<div class="modal" id="sessionOptionsModal">
|
||||||
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
|
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
|
||||||
|
|||||||
@@ -5317,20 +5317,6 @@ kbd {
|
|||||||
scroll-behavior: smooth;
|
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 */
|
/* Custom scrollbar for subagent window body */
|
||||||
.subagent-window-body::-webkit-scrollbar {
|
.subagent-window-body::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -5499,187 +5485,6 @@ kbd {
|
|||||||
50% { opacity: 1; }
|
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 (Bash File Viewers) ========== */
|
||||||
|
|
||||||
.project-insights-panel {
|
.project-insights-panel {
|
||||||
|
|||||||
@@ -140,14 +140,3 @@ export const SessionInputWithLimitSchema = z.object({
|
|||||||
input: z.string().max(100000), // 100KB max input
|
input: z.string().max(100000), // 100KB max input
|
||||||
useScreen: z.boolean().optional(),
|
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 { createRequire } from 'node:module';
|
||||||
import { RunSummaryTracker } from '../run-summary.js';
|
import { RunSummaryTracker } from '../run-summary.js';
|
||||||
import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js';
|
import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js';
|
||||||
import { TeamWatcher } from '../team-watcher.js';
|
|
||||||
|
|
||||||
// Load version from package.json
|
// Load version from package.json
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
@@ -54,9 +53,6 @@ import {
|
|||||||
type PersistedRespawnConfig,
|
type PersistedRespawnConfig,
|
||||||
type NiceConfig,
|
type NiceConfig,
|
||||||
type ImageDetectedEvent,
|
type ImageDetectedEvent,
|
||||||
type TeamConfig,
|
|
||||||
type TeamTask,
|
|
||||||
type InboxMessage,
|
|
||||||
DEFAULT_NICE_CONFIG,
|
DEFAULT_NICE_CONFIG,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import {
|
import {
|
||||||
@@ -69,7 +65,6 @@ import {
|
|||||||
HookEventSchema,
|
HookEventSchema,
|
||||||
ConfigUpdateSchema,
|
ConfigUpdateSchema,
|
||||||
RespawnConfigSchema,
|
RespawnConfigSchema,
|
||||||
TeammatePaneInputSchema,
|
|
||||||
} from './schemas.js';
|
} from './schemas.js';
|
||||||
import { StaleExpirationMap } from '../utils/index.js';
|
import { StaleExpirationMap } from '../utils/index.js';
|
||||||
|
|
||||||
@@ -377,11 +372,6 @@ export class WebServer extends EventEmitter {
|
|||||||
detected: (event: ImageDetectedEvent) => void;
|
detected: (event: ImageDetectedEvent) => void;
|
||||||
error: (error: Error, sessionId?: string) => void;
|
error: (error: Error, sessionId?: string) => void;
|
||||||
} | null = null;
|
} | 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) {
|
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
|
||||||
super();
|
super();
|
||||||
this.port = port;
|
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> {
|
private async setupRoutes(): Promise<void> {
|
||||||
// Serve static files
|
// Serve static files
|
||||||
await this.app.register(fastifyStatic, {
|
await this.app.register(fastifyStatic, {
|
||||||
@@ -697,18 +517,6 @@ export class WebServer extends EventEmitter {
|
|||||||
// Buffers are fetched on-demand when switching tabs
|
// Buffers are fetched on-demand when switching tabs
|
||||||
this.sendSSE(reply, 'init', this.getLightState());
|
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', () => {
|
req.raw.on('close', () => {
|
||||||
this.sseClients.delete(reply);
|
this.sseClients.delete(reply);
|
||||||
});
|
});
|
||||||
@@ -843,7 +651,7 @@ export class WebServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Session management
|
// 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> => {
|
this.app.post('/api/sessions', async (req): Promise<SessionResponse> => {
|
||||||
// Prevent unbounded session creation
|
// 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 ==========
|
// ========== Hook Events ==========
|
||||||
|
|
||||||
this.app.post('/api/hook-event', async (req) => {
|
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
|
// Stop and remove respawn controller - but save config first for restart recovery
|
||||||
const controller = this.respawnControllers.get(sessionId);
|
const controller = this.respawnControllers.get(sessionId);
|
||||||
if (controller) {
|
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 {
|
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)
|
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
||||||
const getTracker = () => this.runSummaryTrackers.get(sessionId);
|
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');
|
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
|
// Stop image watcher
|
||||||
imageWatcher.stop();
|
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)
|
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
|
||||||
fileStreamManager.destroy();
|
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
|
// Clear remaining Maps that accumulate session references
|
||||||
this.respawnTimers.clear();
|
this.respawnTimers.clear();
|
||||||
this.runSummaryTrackers.clear();
|
this.runSummaryTrackers.clear();
|
||||||
|
|||||||
Reference in New Issue
Block a user