chore: bump version to 0.1486

feat: interactive teammate tmux pane windows with xterm.js terminals
feat: auto-cleanup teams/subagents/pane windows on session delete
fix: only show agent/teammate windows when matching Claudeman tab exists
fix: UTF-8 encoding in teammate pane terminal output (Uint8Array)
fix: standalone pane window cleanup via subagentParentMap lookup
fix: xterm.js dimensions crash with deferred init + null-safe dispose

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-12 13:44:07 +01:00
co-authored by Claude Opus 4.6
parent 59a8f6bd7d
commit a4e778c7c4
22 changed files with 2589 additions and 102 deletions
+1 -1
View File
@@ -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.1485 (must match `package.json` for npm publish) **Version**: 0.1486 (must match `package.json` for npm publish)
## Project Overview ## Project Overview
+11 -11
View File
@@ -52,9 +52,9 @@ Real-time desktop notifications when sessions need attention — never miss a pe
--- ---
### 💾 Persistent Screen Sessions ### 💾 Persistent Sessions
Every Claude session runs inside **GNU Screen** — sessions survive server restarts, network drops, and machine sleep. Every Claude session runs inside **tmux** (or GNU Screen as fallback) — sessions survive server restarts, network drops, and machine sleep.
```bash ```bash
# Your sessions are always recoverable # Your sessions are always recoverable
@@ -63,10 +63,10 @@ CLAUDEMAN_SESSION_ID=abc-123-def
CLAUDEMAN_SCREEN_NAME=claudeman-myproject CLAUDEMAN_SCREEN_NAME=claudeman-myproject
``` ```
- Sessions auto-recover on startup (dual redundancy: `state.json` + `screens.json`) - Sessions auto-recover on startup (dual redundancy: `state.json` + `mux-sessions.json`)
- All settings (respawn, auto-compact, tokens) survive server restarts - All settings (respawn, auto-compact, tokens) survive server restarts
- Ghost session discovery finds orphaned screens - Ghost session discovery finds orphaned mux sessions
- Claude knows it's managed (won't kill its own screen) - Claude knows it's managed (won't kill its own session)
--- ---
@@ -203,7 +203,7 @@ Run **20 parallel sessions** with full visibility:
<img src="docs/screenshots/multi-session-dashboard.png" alt="Multi-Session Dashboard" width="800"> <img src="docs/screenshots/multi-session-dashboard.png" alt="Multi-Session Dashboard" width="800">
</p> </p>
**Monitor Panel** — Real-time screen session monitoring with memory, CPU, and process info: **Monitor Panel** — Real-time session monitoring with memory, CPU, and process info:
<p align="center"> <p align="center">
<img src="docs/screenshots/multi-session-monitor.png" alt="Monitor Panel" width="800"> <img src="docs/screenshots/multi-session-monitor.png" alt="Monitor Panel" width="800">
@@ -322,7 +322,7 @@ npm install -g claudeman
- Node.js 18+ - Node.js 18+
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code/getting-started) installed - [Claude CLI](https://docs.anthropic.com/en/docs/claude-code/getting-started) installed
- GNU Screen (`apt install screen` / `brew install screen`) - tmux (`apt install tmux` / `brew install tmux`) — preferred; GNU Screen as fallback (`CLAUDEMAN_MUX=screen`)
## Getting Started ## Getting Started
@@ -365,7 +365,7 @@ claudeman web
| Tiny text, no context | Full xterm.js terminal, responsive layout | | Tiny text, no context | Full xterm.js terminal, responsive layout |
| No session management | Swipe between sessions, tab badges show status | | No session management | Swipe between sessions, tab badges show status |
| No notifications | Push alerts when Claude needs approval or goes idle | | No notifications | Push alerts when Claude needs approval or goes idle |
| Manual reconnect after drops | GNU Screen persistence — sessions survive anything | | Manual reconnect after drops | tmux persistence — sessions survive anything |
| No agent visibility | See background agents working in real-time | | No agent visibility | See background agents working in real-time |
| No token/cost tracking | Live token counts and cost per session | | No token/cost tracking | Live token counts and cost per session |
| Copy-paste slash commands | One-tap `/init`, `/clear`, `/compact` buttons | | Copy-paste slash commands | One-tap `/init`, `/clear`, `/compact` buttons |
@@ -417,9 +417,9 @@ The mobile UI strips away desktop complexity to keep things fast and focused:
--- ---
### Claudeman Screens (`sc`) — Mobile SSH Alternative ### Claudeman Sessions (`sc`) — Mobile SSH Alternative
If you prefer SSH (via Termius, Blink, etc.), the `sc` command is a thumb-friendly screen session chooser: If you prefer SSH (via Termius, Blink, etc.), the `sc` command is a thumb-friendly session chooser:
```bash ```bash
sc # Interactive chooser sc # Interactive chooser
@@ -517,7 +517,7 @@ flowchart TB
end end
subgraph Persistence["Persistence Layer"] subgraph Persistence["Persistence Layer"]
SCR["GNU Screen Manager"] SCR["Mux Manager<br/><small>(tmux/screen)</small>"]
SS["State Store<br/><small>state.json</small>"] SS["State Store<br/><small>state.json</small>"]
end end
+1 -1
View File
@@ -138,7 +138,7 @@ This means:
| **tmux** | `--teammate-mode tmux` | Split panes | tmux installed | | **tmux** | `--teammate-mode tmux` | Split panes | tmux installed |
| **iTerm2** | Auto-detected | Native split panes | iTerm2 + `it2` CLI | | **iTerm2** | Auto-detected | Native split panes | iTerm2 + `it2` CLI |
**For Claudeman: use `in-process` only.** Claudeman uses GNU Screen, not tmux. **For Claudeman: use `in-process` only.** Claudeman manages its own tmux/screen sessions externally.
**In-process UI elements:** **In-process UI elements:**
- Status bar: `@main @teammate1 @teammate2 ...` with `shift+↑ to expand` - Status bar: `@main @teammate1 @teammate2 ...` with `shift+↑ to expand`
+50 -9
View File
@@ -239,6 +239,10 @@ check_git() {
command -v git &>/dev/null command -v git &>/dev/null
} }
check_tmux() {
command -v tmux &>/dev/null
}
check_screen() { check_screen() {
command -v screen &>/dev/null command -v screen &>/dev/null
} }
@@ -388,6 +392,41 @@ install_node_suse() {
run_as_root zypper install -y nodejs run_as_root zypper install -y nodejs
} }
install_tmux_macos() {
info "Installing tmux via Homebrew..."
brew install tmux
}
install_tmux_debian() {
info "Installing tmux via apt..."
ensure_sudo
run_as_root apt-get update -qq
run_as_root apt-get install -y -qq tmux
}
install_tmux_fedora() {
info "Installing tmux via dnf..."
ensure_sudo
run_as_root dnf install -y tmux
}
install_tmux_arch() {
info "Installing tmux via pacman..."
ensure_sudo
run_as_root pacman -Sy --noconfirm tmux
}
install_tmux_alpine() {
info "Installing tmux via apk..."
run_as_root apk add --no-cache tmux
}
install_tmux_suse() {
info "Installing tmux via zypper..."
ensure_sudo
run_as_root zypper install -y tmux
}
install_screen_macos() { install_screen_macos() {
info "Installing GNU Screen via Homebrew..." info "Installing GNU Screen via Homebrew..."
@@ -810,16 +849,18 @@ main() {
die "npm is not available. Please reinstall Node.js." die "npm is not available. Please reinstall Node.js."
fi fi
# GNU Screen # Terminal multiplexer (tmux preferred, GNU Screen as fallback)
info "Checking GNU Screen..." info "Checking terminal multiplexer..."
if ! check_screen; then if check_tmux; then
if prompt_yes_no "GNU Screen is not installed. Install it now?"; then success "tmux is installed (preferred)"
install_dependency "screen" "$os" "$distro" elif check_screen; then
else success "GNU Screen is installed (fallback — consider installing tmux for better performance)"
die "GNU Screen is required for session persistence."
fi
else else
success "GNU Screen is installed" if prompt_yes_no "tmux is not installed. Install it now?"; then
install_dependency "tmux" "$os" "$distro"
else
die "A terminal multiplexer (tmux or GNU Screen) is required for session persistence."
fi
fi fi
# Claude CLI (warning only) # Claude CLI (warning only)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "claudeman", "name": "claudeman",
"version": "0.1485", "version": "0.1486",
"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",
+16 -15
View File
@@ -60,34 +60,33 @@ function commandExists(cmd) {
} }
/** /**
* Get install instructions for screen based on platform * Get install instructions for tmux based on platform
*/ */
function getScreenInstallInstructions() { function getTmuxInstallInstructions() {
const os = platform(); const os = platform();
if (os === 'darwin') { if (os === 'darwin') {
return [ return [
' macOS: brew install screen', ' macOS: brew install tmux',
]; ];
} }
if (os === 'linux') { if (os === 'linux') {
return [ return [
' Ubuntu/Debian: sudo apt install screen', ' Ubuntu/Debian: sudo apt install tmux',
' Fedora/RHEL: sudo dnf install screen', ' Fedora/RHEL: sudo dnf install tmux',
' Arch Linux: sudo pacman -S screen', ' Arch Linux: sudo pacman -S tmux',
' Alpine: sudo apk add screen', ' Alpine: sudo apk add tmux',
]; ];
} }
if (os === 'win32') { if (os === 'win32') {
return [ return [
' Windows: Use WSL (Windows Subsystem for Linux)', ' Windows: Use WSL (Windows Subsystem for Linux)',
' GNU Screen is not available on native Windows',
]; ];
} }
return [' Please install GNU Screen for your platform']; return [' Please install tmux for your platform'];
} }
// ============================================================================ // ============================================================================
@@ -117,17 +116,19 @@ if (majorVersion < MIN_NODE_VERSION) {
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// 2. Check if GNU Screen is installed // 2. Check if terminal multiplexer is installed (tmux preferred, screen fallback)
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
if (commandExists('screen')) { if (commandExists('tmux')) {
console.log(colors.green('✓ GNU Screen found')); console.log(colors.green('✓ tmux found (preferred)'));
} else if (commandExists('screen')) {
console.log(colors.green('✓ GNU Screen found') + colors.dim(' (fallback — consider installing tmux)'));
} else { } else {
hasWarnings = true; hasWarnings = true;
console.log(colors.yellow('⚠ GNU Screen not found')); console.log(colors.yellow('⚠ No terminal multiplexer found'));
console.log(colors.dim(' Screen is required for session persistence.')); console.log(colors.dim(' tmux is required for session persistence.'));
console.log(colors.dim(' Install:')); console.log(colors.dim(' Install:'));
for (const instruction of getScreenInstallInstructions()) { for (const instruction of getTmuxInstallInstructions()) {
console.log(colors.dim(instruction)); console.log(colors.dim(instruction));
} }
} }
+5 -5
View File
@@ -3,7 +3,7 @@
* *
* Provides shared functionality for AI-powered checkers that spawn fresh Claude CLI * Provides shared functionality for AI-powered checkers that spawn fresh Claude CLI
* sessions to analyze terminal output. This base class handles: * sessions to analyze terminal output. This base class handles:
* - Screen session spawning and cleanup * - Mux session spawning and cleanup
* - Temp file management for output capture * - Temp file management for output capture
* - Polling for completion markers * - Polling for completion markers
* - Cooldown management * - Cooldown management
@@ -11,7 +11,7 @@
* - Event emission for state changes * - Event emission for state changes
* *
* Subclasses implement: * Subclasses implement:
* - `screenNamePrefix`: Prefix for screen session names (e.g., 'claudeman-aicheck-') * - `screenNamePrefix`: Prefix for mux session names (e.g., 'claudeman-aicheck-')
* - `doneMarker`: Completion marker in output file (e.g., '__AICHECK_DONE__') * - `doneMarker`: Completion marker in output file (e.g., '__AICHECK_DONE__')
* - `tempFilePrefix`: Prefix for temp files (e.g., 'claudeman-aicheck') * - `tempFilePrefix`: Prefix for temp files (e.g., 'claudeman-aicheck')
* - `logPrefix`: Prefix for log messages (e.g., '[AiIdleChecker]') * - `logPrefix`: Prefix for log messages (e.g., '[AiIdleChecker]')
@@ -115,7 +115,7 @@ const POLL_INTERVAL_MS = 500;
/** /**
* Abstract base class for AI-powered checkers. * Abstract base class for AI-powered checkers.
* Handles spawning Claude CLI in a screen session to analyze terminal output. * Handles spawning Claude CLI in a mux session to analyze terminal output.
* *
* @template V - The verdict type (e.g., 'IDLE' | 'WORKING' | 'ERROR') * @template V - The verdict type (e.g., 'IDLE' | 'WORKING' | 'ERROR')
* @template C - The configuration type * @template C - The configuration type
@@ -154,7 +154,7 @@ export abstract class AiCheckerBase<
// ========== Abstract Properties ========== // ========== Abstract Properties ==========
/** Prefix for screen session names (e.g., 'claudeman-aicheck-') */ /** Prefix for mux session names (e.g., 'claudeman-aicheck-') */
protected abstract readonly screenNamePrefix: string; protected abstract readonly screenNamePrefix: string;
/** Marker written to temp file when check is complete */ /** Marker written to temp file when check is complete */
@@ -318,7 +318,7 @@ export abstract class AiCheckerBase<
/** /**
* Cancel an in-progress check. * Cancel an in-progress check.
* Kills the screen session and cleans up. * Kills the mux session and cleans up.
*/ */
cancel(): void { cancel(): void {
if (this._status !== 'checking') return; if (this._status !== 'checking') return;
+4 -4
View File
@@ -8,8 +8,8 @@
* 2. **Interactive mode** (`startInteractive`): Start an interactive Claude session * 2. **Interactive mode** (`startInteractive`): Start an interactive Claude session
* 3. **Shell mode**: Run a plain bash shell for debugging/testing * 3. **Shell mode**: Run a plain bash shell for debugging/testing
* *
* The session can optionally run inside a GNU Screen session for persistence * The session can optionally run inside a tmux session (or GNU Screen as fallback)
* across disconnects. It tracks tokens, costs, background tasks, and supports * for persistence across disconnects. It tracks tokens, costs, background tasks, and supports
* auto-clear/auto-compact functionality when token limits are approached. * auto-clear/auto-compact functionality when token limits are approached.
* *
* @module session * @module session
@@ -853,7 +853,7 @@ export class Session extends EventEmitter {
* *
* This spawns Claude CLI with `--dangerously-skip-permissions` flag in * This spawns Claude CLI with `--dangerously-skip-permissions` flag in
* interactive mode. If screen wrapping is enabled, the session runs inside * interactive mode. If screen wrapping is enabled, the session runs inside
* a GNU Screen session for persistence across disconnects. * a tmux session (or GNU Screen as fallback) for persistence across disconnects.
* *
* @throws {Error} If a process is already running in this session * @throws {Error} If a process is already running in this session
* *
@@ -1997,7 +1997,7 @@ export class Session extends EventEmitter {
/** /**
* Stops the session and cleans up resources. * Stops the session and cleans up resources.
* *
* This kills the PTY process and optionally the associated GNU Screen * This kills the PTY process and optionally the associated tmux/screen
* session. All buffers are cleared and the session is marked as stopped. * session. All buffers are cleared and the session is marked as stopped.
* *
* @param killScreen - Whether to also kill the screen session (default: true) * @param killScreen - Whether to also kill the screen session (default: true)
+33 -18
View File
@@ -9,11 +9,11 @@
*/ */
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { readdirSync, readFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import type { TeamConfig, TeamTask, InboxMessage } from './types.js'; import type { TeamConfig, TeamMember, TeamTask, InboxMessage } from './types.js';
import { LRUMap } from './utils/lru-map.js'; import { LRUMap } from './utils/lru-map.js';
// ========== Constants ========== // ========== Constants ==========
@@ -33,7 +33,7 @@ export class TeamWatcher extends EventEmitter {
private inboxCache: LRUMap<string, InboxMessage[]> = new LRUMap({ maxSize: MAX_CACHED_TASKS }); private inboxCache: LRUMap<string, InboxMessage[]> = new LRUMap({ maxSize: MAX_CACHED_TASKS });
// Track config mtimes to avoid re-reading unchanged files // Track config mtimes to avoid re-reading unchanged files
private configMtimes: Map<string, number> = new Map(); private configMtimes: Map<string, number> = new Map();
private taskMtimes: Map<string, number> = new Map(); private taskMtimes: Map<string, string> = new Map();
private inboxMtimes: Map<string, number> = new Map(); private inboxMtimes: Map<string, number> = new Map();
constructor(teamsDir?: string, tasksDir?: string) { constructor(teamsDir?: string, tasksDir?: string) {
@@ -122,6 +122,19 @@ export class TeamWatcher extends EventEmitter {
return activeTasks > 0; return activeTasks > 0;
} }
/** Get teammates that have real tmux panes (not in-process) */
getTmuxPaneTeammates(teamName: string): Array<TeamMember & { tmuxPaneId: string }> {
const team = this.teams.get(teamName);
if (!team) return [];
return team.members
.filter((m): m is TeamMember & { tmuxPaneId: string } =>
m.agentType !== 'team-lead' &&
!!m.tmuxPaneId &&
m.tmuxPaneId !== 'in-process'
);
}
/** Get count of active teammates for a session */ /** Get count of active teammates for a session */
getActiveTeammateCount(sessionId: string): number { getActiveTeammateCount(sessionId: string): number {
const team = this.getTeamForSession(sessionId); const team = this.getTeamForSession(sessionId);
@@ -142,8 +155,6 @@ export class TeamWatcher extends EventEmitter {
} }
private pollTeams(): void { private pollTeams(): void {
if (!existsSync(this.teamsDir)) return;
let entries: string[]; let entries: string[];
try { try {
entries = readdirSync(this.teamsDir); entries = readdirSync(this.teamsDir);
@@ -155,24 +166,25 @@ export class TeamWatcher extends EventEmitter {
for (const entry of entries) { for (const entry of entries) {
const configPath = join(this.teamsDir, entry, 'config.json'); const configPath = join(this.teamsDir, entry, 'config.json');
if (!existsSync(configPath)) continue;
currentTeamNames.add(entry); currentTeamNames.add(entry);
// Check mtime to skip unchanged configs // Check mtime to skip unchanged configs (stat instead of existsSync to avoid TOCTOU)
let mtime: number;
try { try {
const mtime = statSync(configPath).mtimeMs; mtime = statSync(configPath).mtimeMs;
if (this.configMtimes.get(entry) === mtime) continue;
this.configMtimes.set(entry, mtime);
} catch { } catch {
// File doesn't exist or was removed between readdir and stat
continue; continue;
} }
if (this.configMtimes.get(entry) === mtime) continue;
this.configMtimes.set(entry, mtime);
// Skip if locked // Skip if locked
if (this.isLocked(join(this.teamsDir, entry, 'config.json'))) continue; if (this.isLocked(join(this.teamsDir, entry, 'config.json'))) continue;
const config = this.readJson<TeamConfig>(configPath); const config = this.readJson<TeamConfig>(configPath);
if (!config || !config.name) continue; if (!config || !config.name || !config.leadSessionId || !Array.isArray(config.members)) continue;
const existing = this.teams.get(entry); const existing = this.teams.get(entry);
this.teams.set(entry, config); this.teams.set(entry, config);
@@ -198,8 +210,6 @@ export class TeamWatcher extends EventEmitter {
} }
private pollTasks(): void { private pollTasks(): void {
if (!existsSync(this.tasksDir)) return;
let teamDirs: string[]; let teamDirs: string[];
try { try {
teamDirs = readdirSync(this.tasksDir); teamDirs = readdirSync(this.tasksDir);
@@ -216,16 +226,22 @@ export class TeamWatcher extends EventEmitter {
continue; continue;
} }
// Check combined mtime for all task files // Check combined mtime for all task files (use count:max:sum to avoid collisions)
const mtimeKey = teamName; const mtimeKey = teamName;
let combinedMtime = 0; let mtimeSum = 0;
let mtimeMax = 0;
let mtimeCount = 0;
for (const f of taskFiles) { for (const f of taskFiles) {
try { try {
combinedMtime += statSync(join(teamTaskDir, f)).mtimeMs; const mt = statSync(join(teamTaskDir, f)).mtimeMs;
mtimeSum += mt;
if (mt > mtimeMax) mtimeMax = mt;
mtimeCount++;
} catch { } catch {
// File may have been deleted between readdir and stat // File may have been deleted between readdir and stat
} }
} }
const combinedMtime = `${mtimeCount}:${mtimeMax}:${mtimeSum}`;
if (this.taskMtimes.get(mtimeKey) === combinedMtime) continue; if (this.taskMtimes.get(mtimeKey) === combinedMtime) continue;
this.taskMtimes.set(mtimeKey, combinedMtime); this.taskMtimes.set(mtimeKey, combinedMtime);
@@ -249,7 +265,6 @@ export class TeamWatcher extends EventEmitter {
// Inbox files live under ~/.claude/teams/{name}/inboxes/ // Inbox files live under ~/.claude/teams/{name}/inboxes/
for (const [teamName] of this.teams.entries()) { for (const [teamName] of this.teams.entries()) {
const inboxDir = join(this.teamsDir, teamName, 'inboxes'); const inboxDir = join(this.teamsDir, teamName, 'inboxes');
if (!existsSync(inboxDir)) continue;
let inboxFiles: string[]; let inboxFiles: string[];
try { try {
@@ -296,7 +311,7 @@ export class TeamWatcher extends EventEmitter {
private isLocked(path: string): boolean { private isLocked(path: string): boolean {
const lockDir = `${path}.lock`; const lockDir = `${path}.lock`;
try { try {
return existsSync(lockDir) && statSync(lockDir).isDirectory(); return statSync(lockDir).isDirectory();
} catch { } catch {
return false; return false;
} }
+2 -2
View File
@@ -20,10 +20,10 @@ Then update the Project Overview section below.
## Claudeman Environment ## Claudeman Environment
This session is managed by **Claudeman** and runs within a GNU Screen session. This session is managed by **Claudeman** and runs within a tmux session (or GNU Screen as fallback).
**Important**: Check for `CLAUDEMAN_SCREEN=1` environment variable to confirm. **Important**: Check for `CLAUDEMAN_SCREEN=1` environment variable to confirm.
- Do NOT attempt to kill your own screen session - Do NOT attempt to kill your own tmux/screen session
- The session persists across disconnects - your work is safe - The session persists across disconnects - your work is safe
- Token usage, costs, and background tasks are tracked externally - Token usage, costs, and background tasks are tracked externally
+215 -1
View File
@@ -26,7 +26,7 @@ import { spawn, execSync } from 'node:child_process';
import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs'; import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { ProcessStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG } from './types.js'; import { ProcessStats, PersistedRespawnConfig, getErrorMessage, NiceConfig, DEFAULT_NICE_CONFIG, type PaneInfo } from './types.js';
import { wrapWithNice } from './utils/nice-wrapper.js'; import { wrapWithNice } from './utils/nice-wrapper.js';
import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js'; import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats } from './mux-interface.js';
@@ -105,6 +105,9 @@ const SAFE_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
/** Regex to validate working directory paths (no shell metacharacters) */ /** Regex to validate working directory paths (no shell metacharacters) */
const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/; const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/;
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
/** /**
* Validates that a session name contains only safe characters. * Validates that a session name contains only safe characters.
* Prevents command injection via malformed session IDs. * Prevents command injection via malformed session IDs.
@@ -298,6 +301,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Non-critical — session still works with status bar // Non-critical — session still works with status bar
} }
// Enable mouse mode — allows clicking to select tmux panes when
// Claude Code creates agent team split panes within this session.
// With mouse mode, xterm.js forwards click events as escape sequences
// that tmux interprets for pane selection.
// Trade-off: text selection requires Shift+click (minor for web UI users).
try {
execSync(
`tmux set-option -t "${muxName}" mouse on`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
} catch {
// Non-critical — pane clicking won't work but keyboard input still does
}
// Enable 24-bit true color passthrough — without this, tmux downgrades // Enable 24-bit true color passthrough — without this, tmux downgrades
// RGB colors (like Claude's red logo) to the nearest 256-color palette entry. // RGB colors (like Claude's red logo) to the nearest 256-color palette entry.
// Server-wide option, only set once per TmuxManager lifetime to avoid duplicates. // Server-wide option, only set once per TmuxManager lifetime to avoid duplicates.
@@ -877,6 +894,203 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
} }
// ========== Pane Methods (for Agent Team teammate panes) ==========
/**
* Enable mouse mode for an existing tmux session.
* Allows clicking to select panes in agent team split-pane layouts.
*/
enableMouseMode(muxName: string): boolean {
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName);
return false;
}
try {
execSync(
`tmux set-option -t "${muxName}" mouse on`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
return true;
} catch {
return false;
}
}
/**
* List all panes in a tmux session.
* Returns structured info for each pane.
*/
listPanes(muxName: string): PaneInfo[] {
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in listPanes:', muxName);
return [];
}
try {
const output = execSync(
`tmux list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
return output.split('\n').map(line => {
const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
return {
paneId,
paneIndex: parseInt(indexStr, 10),
panePid: parseInt(pidStr, 10),
width: parseInt(widthStr, 10),
height: parseInt(heightStr, 10),
};
}).filter(p => !Number.isNaN(p.paneIndex));
} catch {
return [];
}
}
/**
* Send input to a specific pane within a tmux session.
* Uses the same literal text approach as sendInput() but targets a specific pane.
*/
sendInputToPane(muxName: string, paneTarget: string, input: string): boolean {
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in sendInputToPane:', muxName);
return false;
}
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
console.error('[TmuxManager] Invalid pane target:', paneTarget);
return false;
}
// Build target: sessionName.paneId (e.g., "claudeman-abc12345.%1")
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
try {
const hasCarriageReturn = input.includes('\r');
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) {
execSync(
`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
execSync(
`tmux send-keys -t ${shellescape(target)} Enter`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
} else if (textPart) {
execSync(
`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
} else if (hasCarriageReturn) {
execSync(
`tmux send-keys -t ${shellescape(target)} Enter`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
}
return true;
} catch (err) {
console.error('[TmuxManager] Failed to send input to pane:', err);
return false;
}
}
/**
* Capture the current buffer of a specific pane.
* Returns the pane content with ANSI escape codes preserved.
*/
capturePaneBuffer(muxName: string, paneTarget: string): string | null {
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in capturePaneBuffer:', muxName);
return null;
}
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
console.error('[TmuxManager] Invalid pane target:', paneTarget);
return null;
}
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
try {
return execSync(
`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
} catch (err) {
console.error('[TmuxManager] Failed to capture pane buffer:', err);
return null;
}
}
/**
* Start piping pane output to a file using tmux pipe-pane.
* Only pipes output direction (-O) to avoid echoing input.
*/
startPipePane(muxName: string, paneTarget: string, outputFile: string): boolean {
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in startPipePane:', muxName);
return false;
}
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
console.error('[TmuxManager] Invalid pane target:', paneTarget);
return false;
}
if (!isValidPath(outputFile)) {
console.error('[TmuxManager] Invalid output file path:', outputFile);
return false;
}
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
try {
execSync(
`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
return true;
} catch (err) {
console.error('[TmuxManager] Failed to start pipe-pane:', err);
return false;
}
}
/**
* Stop piping pane output (calling pipe-pane with no command stops piping).
*/
stopPipePane(muxName: string, paneTarget: string): boolean {
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in stopPipePane:', muxName);
return false;
}
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
console.error('[TmuxManager] Invalid pane target:', paneTarget);
return false;
}
const target = paneTarget.startsWith('%')
? `${muxName}.${paneTarget}`
: `${muxName}.%${paneTarget}`;
try {
execSync(
`tmux pipe-pane -t ${shellescape(target)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
);
return true;
} catch (err) {
console.error('[TmuxManager] Failed to stop pipe-pane:', err);
return false;
}
}
getAttachCommand(): string { getAttachCommand(): string {
return 'tmux'; return 'tmux';
} }
+22 -3
View File
@@ -855,9 +855,9 @@ export interface CaseInfo {
// ========== Screen Session Types ========== // ========== Screen Session Types ==========
/** /**
* GNU screen session wrapper * Terminal multiplexer session wrapper.
* *
* Claudeman uses GNU screen for session persistence across server restarts. * Claudeman uses tmux (preferred) or GNU Screen (fallback) for session persistence.
*/ */
/** /**
* Persisted respawn configuration for screen sessions. * Persisted respawn configuration for screen sessions.
@@ -913,7 +913,7 @@ export interface PersistedRespawnConfig {
export interface ScreenSession { export interface ScreenSession {
/** Claudeman session ID */ /** Claudeman session ID */
sessionId: string; sessionId: string;
/** GNU screen session name (claudeman-<id>) */ /** Mux session name (claudeman-<id>) */
screenName: string; screenName: string;
/** Screen process PID */ /** Screen process PID */
pid: number; pid: number;
@@ -1514,6 +1514,25 @@ export interface ImageDetectedEvent {
size: number; size: number;
} }
// ========== Pane Types ==========
/**
* Information about a tmux pane within a session.
* Used for agent team teammate pane management.
*/
export interface PaneInfo {
/** Pane ID (e.g., "%0", "%1") — immutable within a tmux session */
paneId: string;
/** Pane index within the window (0, 1, 2...) */
paneIndex: number;
/** PID of the process running in the pane */
panePid: number;
/** Pane width in columns */
width: number;
/** Pane height in rows */
height: number;
}
// ========== Agent Teams Types ========== // ========== Agent Teams Types ==========
/** /**
+371 -10
View File
@@ -1149,6 +1149,10 @@ class ClaudemanApp {
this.teams = new Map(); // Map<teamName, TeamConfig> this.teams = new Map(); // Map<teamName, TeamConfig>
this.teamTasks = new Map(); // Map<teamName, TeamTask[]> this.teamTasks = new Map(); // Map<teamName, TeamTask[]>
this.teammateMap = new Map(); // Map<agentId-prefix, {name, color, teamName}> for quick lookup 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
@@ -2103,6 +2107,23 @@ class ClaudemanApp {
this.closeSessionLogViewerWindows(data.id); // Close log viewer windows for this session this.closeSessionLogViewerWindows(data.id); // Close log viewer windows for this session
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) {
@@ -2531,7 +2552,7 @@ class ClaudemanApp {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId); this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId);
this.renderScreenSessions(); this.renderScreenSessions();
this.showToast('Screen session died: ' + data.sessionId.slice(0, 8), 'warning'); this.showToast('Mux session died: ' + data.sessionId.slice(0, 8), 'warning');
}); });
addListener('screen:statsUpdated', (e) => { addListener('screen:statsUpdated', (e) => {
@@ -2736,9 +2757,15 @@ class ClaudemanApp {
// Find which Claudeman session owns this subagent (direct claudeSessionId match only) // Find which Claudeman session owns this subagent (direct claudeSessionId match only)
this.findParentSessionForSubagent(data.agentId); this.findParentSessionForSubagent(data.agentId);
// Auto-open window for new active agents // Auto-open window for new active agents — but ONLY if they belong to a Claudeman session tab.
// Agents from external Claude sessions (not managed by Claudeman) should not pop up.
if (data.status === 'active') { if (data.status === 'active') {
this.openSubagentWindow(data.agentId); const agentForCheck = this.subagents.get(data.agentId);
const hasMatchingTab = agentForCheck?.sessionId &&
Array.from(this.sessions.values()).some(s => s.claudeSessionId === agentForCheck.sessionId);
if (hasMatchingTab) {
this.openSubagentWindow(data.agentId);
}
} }
// Ensure connection lines are updated after window is created and DOM settles // Ensure connection lines are updated after window is created and DOM settles
@@ -2903,6 +2930,57 @@ class ClaudemanApp {
this.renderTeamTasksPanel(); 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,
});
// Upgrade any existing subagent windows for this teammate to terminal mode
let foundExisting = false;
for (const [agentId, agent] of this.subagents) {
const info = this.getTeammateInfo(agent);
if (info && info.name === data.teammateName && !this.teammateTerminals.has(agentId)) {
const windowData = this.subagentWindows.get(agentId);
if (windowData) {
this.initTeammateTerminal(agentId, data, windowData.element);
foundExisting = true;
}
}
}
// If no existing subagent window found, create a standalone teammate terminal window
if (!foundExisting) {
this.openTeammateTerminalWindow(data);
}
});
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) => {
@@ -9906,7 +9984,7 @@ class ClaudemanApp {
} }
} }
// ========== Monitor Panel (combined Screen Sessions + Background Tasks) ========== // ========== Monitor Panel (combined Mux Sessions + Background Tasks) ==========
async toggleMonitorPanel() { async toggleMonitorPanel() {
const panel = document.getElementById('monitorPanel'); const panel = document.getElementById('monitorPanel');
@@ -11972,6 +12050,13 @@ class ClaudemanApp {
const agent = this.subagents.get(agentId); const agent = this.subagents.get(agentId);
if (!agent) return; if (!agent) return;
// Only open windows for agents that belong to a Claudeman-managed session tab.
// Agents from external Claude sessions (not tracked by Claudeman) should not pop up.
if (agent.sessionId) {
const hasMatchingTab = Array.from(this.sessions.values()).some(s => s.claudeSessionId === agent.sessionId);
if (!hasMatchingTab) return;
}
// Calculate final position - grid layout to avoid overlaps // Calculate final position - grid layout to avoid overlaps
const windowCount = this.subagentWindows.size; const windowCount = this.subagentWindows.size;
const windowWidth = 420; const windowWidth = 420;
@@ -12133,8 +12218,13 @@ class ClaudemanApp {
win.style.display = 'none'; win.style.display = 'none';
} }
// Render content // Render content — check if this teammate has a tmux pane
this.renderSubagentWindowContent(agentId); const paneInfo = teammateInfo ? this.teammatePanesByName.get(teammateInfo.name) : null;
if (paneInfo) {
this.initTeammateTerminal(agentId, paneInfo, win);
} else {
this.renderSubagentWindowContent(agentId);
}
// Focus on click // Focus on click
win.addEventListener('mousedown', () => { win.addEventListener('mousedown', () => {
@@ -12217,7 +12307,10 @@ class ClaudemanApp {
const toClose = []; const toClose = [];
for (const [agentId, _windowData] of this.subagentWindows) { for (const [agentId, _windowData] of this.subagentWindows) {
const agent = this.subagents.get(agentId); const agent = this.subagents.get(agentId);
if (agent?.parentSessionId === sessionId) { // Check both subagent parentSessionId and subagentParentMap
// (standalone pane windows use subagentParentMap, not subagents map)
const parentFromMap = this.subagentParentMap.get(agentId);
if (agent?.parentSessionId === sessionId || parentFromMap === sessionId) {
toClose.push(agentId); toClose.push(agentId);
} }
} }
@@ -12228,6 +12321,7 @@ class ClaudemanApp {
this.subagents.delete(agentId); this.subagents.delete(agentId);
this.subagentActivity.delete(agentId); this.subagentActivity.delete(agentId);
this.subagentToolResults.delete(agentId); this.subagentToolResults.delete(agentId);
this.subagentParentMap.delete(agentId);
} }
} }
// Also clean up minimized agents for this session // Also clean up minimized agents for this session
@@ -12251,6 +12345,17 @@ class ClaudemanApp {
windowData.element.remove(); windowData.element.remove();
this.subagentWindows.delete(agentId); this.subagentWindows.delete(agentId);
} }
// Clean up teammate terminal if present
const termData = this.teammateTerminals.get(agentId);
if (termData) {
if (termData.resizeObserver) {
termData.resizeObserver.disconnect();
}
if (termData.terminal) {
try { termData.terminal.dispose(); } catch {}
}
this.teammateTerminals.delete(agentId);
}
} }
// Clean up ALL floating windows (called during handleInit to prevent memory leaks on reconnect) // Clean up ALL floating windows (called during handleInit to prevent memory leaks on reconnect)
@@ -12268,6 +12373,16 @@ class ClaudemanApp {
} }
this.subagentWindows.clear(); this.subagentWindows.clear();
// Clean up all teammate terminals
for (const [, termData] of this.teammateTerminals) {
if (termData.resizeObserver) termData.resizeObserver.disconnect();
if (termData.terminal) {
try { termData.terminal.dispose(); } catch {}
}
}
this.teammateTerminals.clear();
this.teammatePanesByName.clear();
// Clean up all log viewer windows with their EventSources and drag listeners // Clean up all log viewer windows with their EventSources and drag listeners
for (const [windowId, data] of this.logViewerWindows) { for (const [windowId, data] of this.logViewerWindows) {
if (data.eventSource) { if (data.eventSource) {
@@ -12460,6 +12575,9 @@ class ClaudemanApp {
} }
renderSubagentWindowContent(agentId) { renderSubagentWindowContent(agentId) {
// Skip if this window has a live terminal (don't overwrite xterm with activity HTML)
if (this.teammateTerminals.has(agentId)) return;
const body = document.getElementById(`subagent-window-body-${agentId}`); const body = document.getElementById(`subagent-window-body-${agentId}`);
if (!body) return; if (!body) return;
@@ -12593,6 +12711,249 @@ class ClaudemanApp {
// ========== Agent Teams ========== // ========== Agent Teams ==========
/** Initialize an xterm.js terminal for a teammate's tmux pane */
initTeammateTerminal(agentId, paneInfo, windowElement) {
const body = windowElement.querySelector('.subagent-window-body');
if (!body) return;
// Clear the activity log content
body.innerHTML = '';
body.classList.add('teammate-terminal-body');
windowElement.classList.add('has-terminal');
const sessionId = paneInfo.sessionId;
// Buffer incoming terminal data until xterm is ready
const pendingData = [];
this.teammateTerminals.set(agentId, {
terminal: null,
fitAddon: null,
paneTarget: paneInfo.paneTarget,
sessionId,
resizeObserver: null,
pendingData,
});
// Defer terminal creation to next frame so the body element has computed dimensions
requestAnimationFrame(() => {
// Safety: if window was closed before we got here, bail out
if (!document.contains(body)) {
this.teammateTerminals.delete(agentId);
return;
}
const terminal = new Terminal({
theme: {
background: '#0d0d0d',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#0d0d0d',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#0d0d0d',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
fontSize: 12,
lineHeight: 1.2,
cursorBlink: true,
cursorStyle: 'block',
scrollback: 5000,
allowTransparency: true,
});
const fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
try {
terminal.open(body);
} catch (err) {
console.warn('[TeammateTerminal] Failed to open terminal:', err);
this.teammateTerminals.delete(agentId);
return;
}
// Wait for terminal renderer to fully initialize before any writes.
// xterm.js needs a few frames after open() before write() is safe.
setTimeout(() => {
try { fitAddon.fit(); } catch {}
// Fetch initial pane buffer
fetch(`/api/sessions/${sessionId}/teammate-pane-buffer/${encodeURIComponent(paneInfo.paneTarget)}`)
.then(r => r.json())
.then(resp => {
if (resp.success && resp.data?.buffer) {
try { terminal.write(resp.data.buffer); } catch {}
}
})
.catch(err => console.error('[TeammateTerminal] Failed to fetch buffer:', err));
// Flush any data that arrived while terminal was initializing
for (const chunk of pendingData) {
try { terminal.write(chunk); } catch {}
}
pendingData.length = 0;
}, 100);
// Forward keyboard input to the teammate's pane
terminal.onData((data) => {
fetch(`/api/sessions/${sessionId}/teammate-pane-input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paneTarget: paneInfo.paneTarget, input: data }),
}).catch(err => console.error('[TeammateTerminal] Failed to send input:', err));
});
// Resize observer to refit terminal when window is resized
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { try { fitAddon.fit(); } catch {} });
});
resizeObserver.observe(body);
// Update the stored entry with the real terminal
const entry = this.teammateTerminals.get(agentId);
if (entry) {
entry.terminal = terminal;
entry.fitAddon = fitAddon;
entry.resizeObserver = resizeObserver;
}
});
}
/** Open a standalone terminal window for a tmux-pane teammate (no subagent entry needed) */
openTeammateTerminalWindow(paneData) {
// Only open if the session has a tab in Claudeman
if (!this.sessions.has(paneData.sessionId)) return;
// Use pane target as the unique ID for this window
const windowId = `pane-${paneData.paneTarget}`;
// If window already exists, focus it
if (this.subagentWindows.has(windowId)) {
const existing = this.subagentWindows.get(windowId);
if (existing.hidden) {
existing.element.style.display = 'flex';
existing.hidden = false;
}
existing.element.style.zIndex = ++this.subagentWindowZIndex;
if (existing.minimized) {
this.restoreSubagentWindow(windowId);
}
return;
}
// Calculate position
const windowCount = this.subagentWindows.size;
const windowWidth = 550;
const windowHeight = 400;
const gap = 20;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const startX = 50;
const startY = 120;
const maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1;
const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1;
const col = windowCount % maxCols;
const row = Math.floor(windowCount / maxCols) % maxRows;
let finalX = startX + col * (windowWidth + gap);
let finalY = startY + row * (windowHeight + gap);
finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10));
finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10));
// Color badge
const colorClass = paneData.color || 'blue';
// Create window element
const win = document.createElement('div');
win.className = 'subagent-window has-terminal';
win.id = `subagent-window-${windowId}`;
win.style.zIndex = ++this.subagentWindowZIndex;
win.style.left = `${finalX}px`;
win.style.top = `${finalY}px`;
win.style.width = `${windowWidth}px`;
win.style.height = `${windowHeight}px`;
win.innerHTML = `
<div class="subagent-window-header">
<div class="subagent-window-title" title="Teammate terminal: ${this.escapeHtml(paneData.teammateName)} (pane ${paneData.paneTarget})">
<span class="icon" style="color: var(--team-color-${colorClass}, #339af0)">⬤</span>
<span class="id">${this.escapeHtml(paneData.teammateName)}</span>
<span class="status running">terminal</span>
</div>
<div class="subagent-window-actions">
<button onclick="app.closeSubagentWindow('${windowId}')" title="Minimize to tab">─</button>
</div>
</div>
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
</div>
`;
document.body.appendChild(win);
// Make draggable
const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header'));
// Make resizable if method exists
if (typeof this.makeWindowResizable === 'function') {
this.makeWindowResizable(win);
}
// Check visibility based on active session
const settings = this.loadAppSettingsFromStorage();
const activeTabOnly = settings.subagentActiveTabOnly ?? true;
const shouldHide = activeTabOnly && paneData.sessionId !== this.activeSessionId;
// Store reference
this.subagentWindows.set(windowId, {
element: win,
minimized: false,
hidden: shouldHide,
dragListeners,
description: `Teammate: ${paneData.teammateName}`,
});
// Also add to subagentParentMap for tab-based visibility
this.subagentParentMap.set(windowId, paneData.sessionId);
if (shouldHide) {
win.style.display = 'none';
}
// Focus on click
win.addEventListener('mousedown', () => {
win.style.zIndex = ++this.subagentWindowZIndex;
});
// Resize observer for connection lines
const resizeObserver = new ResizeObserver(() => {
this.updateConnectionLines();
});
resizeObserver.observe(win);
this.subagentWindows.get(windowId).resizeObserver = resizeObserver;
// Init the xterm.js terminal
this.initTeammateTerminal(windowId, paneData, win);
// Animate in
requestAnimationFrame(() => {
win.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
win.style.transform = 'scale(1)';
win.style.opacity = '1';
});
}
/** Rebuild the teammate lookup map from all team configs */ /** Rebuild the teammate lookup map from all team configs */
rebuildTeammateMap() { rebuildTeammateMap() {
this.teammateMap.clear(); this.teammateMap.clear();
@@ -13625,7 +13986,7 @@ class ClaudemanApp {
} }
} }
// ========== Screen Sessions (in Monitor Panel) ========== // ========== Mux Sessions (in Monitor Panel) ==========
async loadScreens() { async loadScreens() {
try { try {
@@ -13974,7 +14335,7 @@ class ClaudemanApp {
const body = document.getElementById('screenSessionsBody'); const body = document.getElementById('screenSessionsBody');
if (!this.screenSessions || this.screenSessions.length === 0) { if (!this.screenSessions || this.screenSessions.length === 0) {
body.innerHTML = '<div class="monitor-empty">No screen sessions</div>'; body.innerHTML = '<div class="monitor-empty">No mux sessions</div>';
return; return;
} }
@@ -14049,7 +14410,7 @@ class ClaudemanApp {
} }
async killScreen(sessionId) { async killScreen(sessionId) {
if (!confirm('Kill this screen session?')) return; if (!confirm('Kill this mux session?')) return;
try { try {
await fetch(`/api/screens/${sessionId}`, { method: 'DELETE' }); await fetch(`/api/screens/${sessionId}`, { method: 'DELETE' });
+1 -1
View File
@@ -201,7 +201,7 @@
<div class="welcome-overlay" id="welcomeOverlay"> <div class="welcome-overlay" id="welcomeOverlay">
<div class="welcome-content"> <div class="welcome-content">
<h1 class="welcome-title">Claudeman Terminal</h1> <h1 class="welcome-title">Claudeman Terminal</h1>
<p class="welcome-desc">Each instance opens a persistent GNU Screen session running Claude or Shell.</p> <p class="welcome-desc">Each instance opens a persistent tmux session running Claude or Shell.</p>
<p class="welcome-desc">Sessions stay alive for autonomous work, even if you close this browser.</p> <p class="welcome-desc">Sessions stay alive for autonomous work, even if you close this browser.</p>
<div class="welcome-actions"> <div class="welcome-actions">
<button class="welcome-btn welcome-btn-primary" onclick="app.runClaude()"> <button class="welcome-btn welcome-btn-primary" onclick="app.runClaude()">
+14
View File
@@ -5317,6 +5317,20 @@ 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;
+12 -1
View File
@@ -79,7 +79,7 @@ export const QuickStartSchema = z.object({
export const HookEventSchema = z.object({ export const HookEventSchema = z.object({
event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop', 'teammate_idle', 'task_completed']), event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop', 'teammate_idle', 'task_completed']),
sessionId: z.string().min(1), sessionId: z.string().min(1),
data: z.unknown().optional(), data: z.record(z.string(), z.unknown()).optional(),
}); });
// ========== Configuration ========== // ========== Configuration ==========
@@ -140,3 +140,14 @@ 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),
});
+251 -18
View File
@@ -14,15 +14,16 @@ import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import path, { join, dirname, resolve, relative, isAbsolute } from 'node:path'; import path, { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readdirSync, readFileSync, rmSync, unlinkSync } from 'node:fs';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { execSync } from 'node:child_process'; import { execSync, spawn, type ChildProcess } from 'node:child_process';
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os'; import { homedir, tmpdir, totalmem, freemem, loadavg, cpus } from 'node:os';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool } from '../session.js'; import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool } from '../session.js';
import { fileStreamManager } from '../file-stream-manager.js'; import { fileStreamManager } from '../file-stream-manager.js';
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js'; import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js'; import type { TerminalMultiplexer } from '../mux-interface.js';
import { TmuxManager } from '../tmux-manager.js';
import { createMultiplexer } from '../mux-factory.js'; import { createMultiplexer } from '../mux-factory.js';
import { getStore } from '../state-store.js'; import { getStore } from '../state-store.js';
import { generateClaudeMd } from '../templates/claude-md.js'; import { generateClaudeMd } from '../templates/claude-md.js';
@@ -68,6 +69,7 @@ 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';
@@ -376,6 +378,9 @@ export class WebServer extends EventEmitter {
error: (error: Error, sessionId?: string) => void; error: (error: Error, sessionId?: string) => void;
} | null = null; } | null = null;
private teamWatcher: TeamWatcher = new TeamWatcher(); 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();
@@ -502,12 +507,15 @@ export class WebServer extends EventEmitter {
private setupTeamWatcherListeners(): void { private setupTeamWatcherListeners(): void {
this.teamWatcher.on('teamCreated', (team: TeamConfig) => { this.teamWatcher.on('teamCreated', (team: TeamConfig) => {
this.broadcast('team:created', team); this.broadcast('team:created', team);
this.startPaneStreamsForTeam(team);
}); });
this.teamWatcher.on('teamUpdated', (team: TeamConfig) => { this.teamWatcher.on('teamUpdated', (team: TeamConfig) => {
this.broadcast('team:updated', team); this.broadcast('team:updated', team);
this.startPaneStreamsForTeam(team);
}); });
this.teamWatcher.on('teamRemoved', (team: TeamConfig) => { this.teamWatcher.on('teamRemoved', (team: TeamConfig) => {
this.broadcast('team:removed', team); this.broadcast('team:removed', team);
this.stopPaneStreamsForSession(team.leadSessionId);
}); });
this.teamWatcher.on('taskUpdated', (data: { teamName: string; tasks: TeamTask[] }) => { this.teamWatcher.on('taskUpdated', (data: { teamName: string; tasks: TeamTask[] }) => {
// Filter out internal tasks for broadcast // Filter out internal tasks for broadcast
@@ -519,6 +527,153 @@ export class WebServer extends EventEmitter {
}); });
} }
/** 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, {
@@ -542,6 +697,18 @@ 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);
}); });
@@ -1548,7 +1715,7 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`); return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
} }
// Use writeViaScreen for programmatic input (more reliable for screen sessions) // Use writeViaScreen for programmatic input (more reliable for mux sessions)
let success = false; let success = false;
if (useScreen) { if (useScreen) {
success = session.writeViaScreen(inputStr); success = session.writeViaScreen(inputStr);
@@ -1697,7 +1864,7 @@ export class WebServer extends EventEmitter {
controller.start(); controller.start();
// Persist respawn config to screen session and state.json // Persist respawn config to mux session and state.json
this.saveRespawnConfig(id, controller.getConfig()); this.saveRespawnConfig(id, controller.getConfig());
this.persistSessionState(session); this.persistSessionState(session);
@@ -1888,7 +2055,7 @@ export class WebServer extends EventEmitter {
this.setupTimedRespawn(id, body.durationMinutes); this.setupTimedRespawn(id, body.durationMinutes);
} }
// Persist respawn config to screen session and state.json // Persist respawn config to mux session and state.json
this.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes); this.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes);
this.persistSessionState(session); this.persistSessionState(session);
@@ -3155,7 +3322,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return { return {
success: true, success: true,
nice: session.niceConfig, nice: session.niceConfig,
note: 'Nice priority only affects newly created screen sessions, not currently running ones.', note: 'Nice priority only affects newly created mux sessions, not currently running ones.',
}; };
}); });
@@ -3231,7 +3398,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}; };
}); });
// Kill a screen session // Kill a mux session
this.app.delete('/api/screens/:sessionId', async (req) => { this.app.delete('/api/screens/:sessionId', async (req) => {
const { sessionId } = req.params as { sessionId: string }; const { sessionId } = req.params as { sessionId: string };
const success = await this.mux.killSession(sessionId); const success = await this.mux.killSession(sessionId);
@@ -3379,6 +3546,63 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return { success: true, data: tasks }; 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) => {
@@ -3407,7 +3631,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
} }
// Start transcript watching if transcript_path is provided // Start transcript watching if transcript_path is provided
if (data && typeof data === 'object' && 'transcript_path' in data) { if (data && 'transcript_path' in data) {
const transcriptPath = String(data.transcript_path); const transcriptPath = String(data.transcript_path);
if (transcriptPath) { if (transcriptPath) {
this.startTranscriptWatcher(sessionId, transcriptPath); this.startTranscriptWatcher(sessionId, transcriptPath);
@@ -3415,7 +3639,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
} }
// Sanitize forwarded data: only include known safe fields, limit size // Sanitize forwarded data: only include known safe fields, limit size
const safeData = sanitizeHookData(data as Record<string, unknown> | undefined); const safeData = sanitizeHookData(data);
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData }); this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
// Track in run summary // Track in run summary
@@ -3514,7 +3738,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.store.setSession(session.id, state); this.store.setSession(session.id, state);
} }
// Helper to save respawn config to screen session for persistence // Helper to save respawn config to mux session for persistence
private saveRespawnConfig(sessionId: string, config: RespawnConfig, durationMinutes?: number): void { private saveRespawnConfig(sessionId: string, config: RespawnConfig, durationMinutes?: number): void {
const persistedConfig: PersistedRespawnConfig = { const persistedConfig: PersistedRespawnConfig = {
enabled: config.enabled, enabled: config.enabled,
@@ -3605,6 +3829,14 @@ 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) {
@@ -4711,7 +4943,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
for (const client of this.sseClients) { for (const client of this.sseClients) {
try { try {
// Check if the underlying socket is still writable // Check if the underlying socket is still writable
const socket = client.raw.socket || (client.raw as any).connection; const socket = client.raw.socket;
if (!socket || socket.destroyed || !socket.writable) { if (!socket || socket.destroyed || !socket.writable) {
deadClients.push(client); deadClients.push(client);
} else { } else {
@@ -4757,7 +4989,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
async start(): Promise<void> { async start(): Promise<void> {
await this.setupRoutes(); await this.setupRoutes();
// Restore screen sessions BEFORE accepting connections // Restore mux sessions BEFORE accepting connections
// This prevents race conditions where clients connect before state is ready // This prevents race conditions where clients connect before state is ready
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions // CRITICAL: Skip in test mode to prevent tests from picking up user sessions
if (!this.testMode) { if (!this.testMode) {
@@ -4853,11 +5085,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
const { alive, dead, discovered } = await this.mux.reconcileSessions(); const { alive, dead, discovered } = await this.mux.reconcileSessions();
if (discovered.length > 0) { if (discovered.length > 0) {
console.log(`[Server] Discovered ${discovered.length} unknown screen session(s)`); console.log(`[Server] Discovered ${discovered.length} unknown mux session(s)`);
} }
if (alive.length > 0 || discovered.length > 0) { if (alive.length > 0 || discovered.length > 0) {
console.log(`[Server] Found ${alive.length + discovered.length} alive screen session(s) from previous run`); console.log(`[Server] Found ${alive.length + discovered.length} alive mux session(s) from previous run`);
// For each alive screen, create a Session object if it doesn't exist // For each alive screen, create a Session object if it doesn't exist
const screens = this.mux.getSessions(); const screens = this.mux.getSessions();
@@ -4996,10 +5228,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
} }
if (dead.length > 0) { if (dead.length > 0) {
console.log(`[Server] Cleaned up ${dead.length} dead screen session(s)`); console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`);
} }
} catch (err) { } catch (err) {
console.error('[Server] Failed to restore screen sessions:', err); console.error('[Server] Failed to restore mux sessions:', err);
} }
} }
@@ -5104,7 +5336,8 @@ 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 // Stop team watcher and clean up pane streams
this.cleanupAllPaneStreams();
this.teamWatcher.removeAllListeners(); this.teamWatcher.removeAllListeners();
this.teamWatcher.stop(); this.teamWatcher.stop();
+570
View File
@@ -0,0 +1,570 @@
/**
* @fileoverview Tests for BashToolParser
*
* Tests the terminal output parser that detects Bash tool invocations,
* extracts file paths, and tracks tool lifecycle (start/completion).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { BashToolParser } from '../src/bash-tool-parser.js';
describe('BashToolParser', () => {
let parser: BashToolParser;
beforeEach(() => {
vi.useFakeTimers();
parser = new BashToolParser({ sessionId: 'test-session' });
});
afterEach(() => {
parser.destroy();
vi.useRealTimers();
});
// ========== Constructor & Accessors ==========
describe('constructor', () => {
it('should initialize with session ID', () => {
expect(parser.sessionId).toBe('test-session');
});
it('should be enabled by default', () => {
expect(parser.enabled).toBe(true);
});
it('should accept enabled: false', () => {
const p = new BashToolParser({ sessionId: 's', enabled: false });
expect(p.enabled).toBe(false);
p.destroy();
});
it('should accept a custom working directory', () => {
const p = new BashToolParser({ sessionId: 's', workingDir: '/tmp/test' });
expect(p.workingDir).toBe('/tmp/test');
p.destroy();
});
it('should start with no active tools', () => {
expect(parser.activeTools).toEqual([]);
});
});
// ========== Enable / Disable ==========
describe('enable / disable', () => {
it('should toggle enabled state', () => {
parser.disable();
expect(parser.enabled).toBe(false);
parser.enable();
expect(parser.enabled).toBe(true);
});
it('should ignore processTerminalData when disabled', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.disable();
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
vi.advanceTimersByTime(100);
expect(handler).not.toHaveBeenCalled();
});
});
// ========== Path Normalization ==========
describe('normalizePath', () => {
it('should expand ~ to home directory', () => {
const home = process.env.HOME || '/home/user';
expect(parser.normalizePath('~/foo/bar')).toBe(`${home}/foo/bar`);
});
it('should expand bare ~', () => {
const home = process.env.HOME || '/home/user';
expect(parser.normalizePath('~')).toBe(home);
});
it('should leave absolute paths unchanged', () => {
expect(parser.normalizePath('/var/log/app.log')).toBe('/var/log/app.log');
});
it('should resolve .. components', () => {
expect(parser.normalizePath('/a/b/../c')).toBe('/a/c');
});
it('should resolve . components', () => {
expect(parser.normalizePath('/a/./b')).toBe('/a/b');
});
it('should not go above root with ..', () => {
expect(parser.normalizePath('/../../etc/passwd')).toBe('/etc/passwd');
});
it('should return empty string for empty input', () => {
expect(parser.normalizePath('')).toBe('');
});
it('should trim whitespace', () => {
expect(parser.normalizePath(' /var/log ')).toBe('/var/log');
});
});
// ========== Path Equivalence ==========
describe('pathsAreEquivalent', () => {
it('should match identical paths', () => {
expect(parser.pathsAreEquivalent('/var/log/a.log', '/var/log/a.log')).toBe(true);
});
it('should match after normalization', () => {
expect(parser.pathsAreEquivalent('/var/log/../log/a.log', '/var/log/a.log')).toBe(true);
});
it('should not match different files', () => {
expect(parser.pathsAreEquivalent('/var/log/a.log', '/var/log/b.log')).toBe(false);
});
it('should match shallow root path with working dir path (same filename)', () => {
const p = new BashToolParser({ sessionId: 's', workingDir: '/home/user/project' });
// /test.txt is a shallow root path and /home/user/project/test.txt is in workdir
expect(p.pathsAreEquivalent('/test.txt', '/home/user/project/test.txt')).toBe(true);
p.destroy();
});
it('should not match shallow root path with non-workdir path', () => {
const p = new BashToolParser({ sessionId: 's', workingDir: '/home/user/project' });
// Both are outside working dir
expect(p.pathsAreEquivalent('/test.txt', '/other/dir/test.txt')).toBe(false);
p.destroy();
});
});
// ========== Path Deduplication ==========
describe('deduplicatePaths', () => {
it('should return single path as-is', () => {
expect(parser.deduplicatePaths(['/a/b'])).toEqual(['/a/b']);
});
it('should remove exact duplicates', () => {
const result = parser.deduplicatePaths(['/a/b', '/a/b']);
expect(result).toHaveLength(1);
});
it('should remove normalized duplicates', () => {
const result = parser.deduplicatePaths(['/a/b/../c', '/a/c']);
expect(result).toHaveLength(1);
});
it('should keep different paths', () => {
const result = parser.deduplicatePaths(['/a/b', '/c/d']);
expect(result).toHaveLength(2);
});
it('should handle empty array', () => {
expect(parser.deduplicatePaths([])).toEqual([]);
});
});
// ========== Tool Detection ==========
describe('processTerminalData - tool start', () => {
it('should detect ● Bash(tail -f /var/log/syslog) pattern', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
// toolStart is emitted synchronously (not debounced)
expect(handler).toHaveBeenCalledTimes(1);
const tool = handler.mock.calls[0][0];
expect(tool.command).toBe('tail -f /var/log/syslog');
expect(tool.filePaths).toContain('/var/log/syslog');
expect(tool.status).toBe('running');
expect(tool.sessionId).toBe('test-session');
});
it('should detect tool with timeout', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('● Bash(tail -f /tmp/out.log) timeout: 5m 0s\n');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].timeout).toBe('5m 0s');
});
it('should detect cat command', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('● Bash(cat /etc/hostname)\n');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].filePaths).toContain('/etc/hostname');
});
it('should detect head command', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('● Bash(head -n 20 /var/log/kern.log)\n');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].filePaths).toContain('/var/log/kern.log');
});
it('should detect grep command with file path', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('● Bash(grep error /var/log/app.log)\n');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].filePaths).toContain('/var/log/app.log');
});
it('should detect watch command', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('● Bash(watch /tmp/status.txt)\n');
expect(handler).toHaveBeenCalledTimes(1);
});
it('should NOT detect non-file-viewer commands', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('● Bash(echo hello)\n');
expect(handler).not.toHaveBeenCalled();
});
it('should extract multiple file paths from a single command', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('● Bash(tail -f /var/log/a.log /var/log/b.log)\n');
expect(handler).toHaveBeenCalledTimes(1);
const paths = handler.mock.calls[0][0].filePaths;
expect(paths).toContain('/var/log/a.log');
expect(paths).toContain('/var/log/b.log');
});
it('should skip /dev/null paths', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
// grep with /dev/null shouldn't produce a tool (only invalid path)
parser.processTerminalData('● Bash(cat /dev/null)\n');
expect(handler).not.toHaveBeenCalled();
});
it('should handle ANSI codes in the output', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('\x1b[1m● Bash(tail -f /var/log/test.log)\x1b[0m\n');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].filePaths).toContain('/var/log/test.log');
});
});
// ========== Tool Completion ==========
describe('processTerminalData - tool completion', () => {
it('should emit toolEnd on ✓ Bash', () => {
const endHandler = vi.fn();
parser.on('toolEnd', endHandler);
// Start a tool first
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
// Complete it
parser.processTerminalData('✓ Bash\n');
expect(endHandler).toHaveBeenCalledTimes(1);
expect(endHandler.mock.calls[0][0].status).toBe('completed');
});
it('should emit toolEnd on ✗ Bash (failure)', () => {
const endHandler = vi.fn();
parser.on('toolEnd', endHandler);
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
parser.processTerminalData('✗ Bash\n');
expect(endHandler).toHaveBeenCalledTimes(1);
expect(endHandler.mock.calls[0][0].status).toBe('completed');
});
it('should remove completed tool after 2000ms delay', () => {
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
expect(parser.activeTools).toHaveLength(1);
parser.processTerminalData('✓ Bash\n');
// Tool still present (in completed state)
expect(parser.activeTools).toHaveLength(1);
vi.advanceTimersByTime(2000);
expect(parser.activeTools).toHaveLength(0);
});
});
// ========== Text Command Detection ==========
describe('processTerminalData - text command fallback', () => {
it('should detect plain text "tail -f /path" suggestions', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('tail -f /var/log/app.log\n');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].filePaths).toContain('/var/log/app.log');
});
it('should auto-remove text command suggestions after 30s', () => {
parser.processTerminalData('tail -f /var/log/app.log\n');
expect(parser.activeTools).toHaveLength(1);
vi.advanceTimersByTime(30000);
expect(parser.activeTools).toHaveLength(0);
});
});
// ========== Log File Mention Detection ==========
describe('processTerminalData - log file mentions', () => {
it('should detect .log file paths mentioned in text', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('The output is saved to /tmp/output.log for review\n');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].filePaths).toContain('/tmp/output.log');
});
it('should detect .txt file paths mentioned in text', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('See /tmp/results.txt for details\n');
expect(handler).toHaveBeenCalledTimes(1);
});
it('should detect .out file paths mentioned in text', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('Wrote to /tmp/build.out\n');
expect(handler).toHaveBeenCalledTimes(1);
});
it('should auto-remove log mentions after 60s', () => {
parser.processTerminalData('Wrote to /tmp/build.log\n');
expect(parser.activeTools).toHaveLength(1);
vi.advanceTimersByTime(60000);
expect(parser.activeTools).toHaveLength(0);
});
it('should strip trailing punctuation from paths', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('Check /tmp/output.log, please\n');
expect(handler).toHaveBeenCalledTimes(1);
const filePaths = handler.mock.calls[0][0].filePaths;
expect(filePaths[0]).not.toMatch(/,$/);
});
});
// ========== Deduplication ==========
describe('cross-pattern deduplication', () => {
it('should not add duplicate file paths from different patterns', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
// First: detected as a Bash tool
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
expect(handler).toHaveBeenCalledTimes(1);
// Second: same path as text command — should be skipped
parser.processTerminalData('tail -f /var/log/syslog\n');
expect(handler).toHaveBeenCalledTimes(1); // still 1
});
it('should track file paths correctly', () => {
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
expect(parser.isFilePathTracked('/var/log/syslog')).toBe(true);
expect(parser.isFilePathTracked('/var/log/other.log')).toBe(false);
});
});
// ========== Max Tools Limit ==========
describe('max active tools limit', () => {
it('should evict oldest tool when limit reached', () => {
// The limit is MAX_ACTIVE_TOOLS = 20
for (let i = 0; i < 21; i++) {
parser.processTerminalData(`● Bash(tail -f /var/log/file${i}.log)\n`);
}
// Should have exactly 20 tools
expect(parser.activeTools.length).toBeLessThanOrEqual(20);
});
});
// ========== Line Buffer ==========
describe('line buffer handling', () => {
it('should handle data split across multiple chunks', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
// Split the line across two processTerminalData calls
parser.processTerminalData('● Bash(tail -f /var/');
expect(handler).not.toHaveBeenCalled();
parser.processTerminalData('log/syslog)\n');
expect(handler).toHaveBeenCalledTimes(1);
});
it('should handle multiple lines in one chunk', () => {
const startHandler = vi.fn();
const endHandler = vi.fn();
parser.on('toolStart', startHandler);
parser.on('toolEnd', endHandler);
parser.processTerminalData(
'● Bash(tail -f /var/log/a.log)\n✓ Bash\n● Bash(cat /var/log/b.log)\n',
);
expect(startHandler).toHaveBeenCalledTimes(2);
expect(endHandler).toHaveBeenCalledTimes(1);
});
it('should truncate line buffer if it exceeds max size', () => {
// Generate data larger than MAX_LINE_BUFFER_SIZE (64KB)
const longLine = 'x'.repeat(70 * 1024);
parser.processTerminalData(longLine);
// Should not throw; parser continues working
const handler = vi.fn();
parser.on('toolStart', handler);
parser.processTerminalData('● Bash(tail -f /var/log/test.log)\n');
expect(handler).toHaveBeenCalledTimes(1);
});
});
// ========== Debounced Updates ==========
describe('debounced updates', () => {
it('should emit toolsUpdate after debounce period', () => {
const handler = vi.fn();
parser.on('toolsUpdate', handler);
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
// Not emitted yet (debounced at 50ms)
expect(handler).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0]).toHaveLength(1);
});
});
// ========== Reset ==========
describe('reset', () => {
it('should clear all tracked tools', () => {
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
expect(parser.activeTools).toHaveLength(1);
parser.reset();
expect(parser.activeTools).toHaveLength(0);
});
});
// ========== Destroy ==========
describe('destroy', () => {
it('should prevent further processing after destroy', () => {
const handler = vi.fn();
parser.on('toolStart', handler);
parser.destroy();
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
expect(handler).not.toHaveBeenCalled();
});
it('should clear active tools and timers', () => {
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
parser.destroy();
expect(parser.activeTools).toHaveLength(0);
});
});
// ========== isPathInWorkingDir ==========
describe('isPathInWorkingDir', () => {
it('should return true for paths inside working dir', () => {
const p = new BashToolParser({ sessionId: 's', workingDir: '/home/user/project' });
expect(p.isPathInWorkingDir('/home/user/project/src/main.ts')).toBe(true);
p.destroy();
});
it('should return false for paths outside working dir', () => {
const p = new BashToolParser({ sessionId: 's', workingDir: '/home/user/project' });
expect(p.isPathInWorkingDir('/var/log/app.log')).toBe(false);
p.destroy();
});
it('should return true for the working dir itself', () => {
const p = new BashToolParser({ sessionId: 's', workingDir: '/home/user/project' });
expect(p.isPathInWorkingDir('/home/user/project')).toBe(true);
p.destroy();
});
});
// ========== setWorkingDir ==========
describe('setWorkingDir', () => {
it('should update the working directory', () => {
parser.setWorkingDir('/new/dir');
expect(parser.workingDir).toBe('/new/dir');
});
});
// ========== getTrackedPaths ==========
describe('getTrackedPaths', () => {
it('should return raw and normalized paths for running tools', () => {
parser.processTerminalData('● Bash(tail -f /var/log/syslog)\n');
const tracked = parser.getTrackedPaths();
expect(tracked).toHaveLength(1);
expect(tracked[0].raw).toBe('/var/log/syslog');
expect(tracked[0].normalized).toBe('/var/log/syslog');
});
it('should return empty array when no tools are active', () => {
expect(parser.getTrackedPaths()).toEqual([]);
});
});
});
+531
View File
@@ -0,0 +1,531 @@
/**
* @fileoverview Tests for FileStreamManager
*
* Tests stream creation, path validation, concurrent stream limits,
* cleanup, and inactivity timeout logic.
*
* Uses mocked child_process.spawn to avoid real tail processes.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import type { ChildProcess } from 'node:child_process';
// Mock child_process.spawn
const mockSpawn = vi.fn();
vi.mock('node:child_process', () => ({
spawn: (...args: unknown[]) => mockSpawn(...args),
}));
// Mock fs functions
vi.mock('node:fs', async (importOriginal) => {
const orig = await importOriginal<typeof import('node:fs')>();
return {
...orig,
existsSync: vi.fn(() => true),
statSync: vi.fn(() => ({ size: 1024 })),
};
});
import { FileStreamManager } from '../src/file-stream-manager.js';
import { existsSync, statSync } from 'node:fs';
/**
* Creates a mock ChildProcess-like EventEmitter with stdout/stderr streams.
*/
function createMockProcess(pid = 12345): ChildProcess {
const proc = new EventEmitter() as unknown as ChildProcess;
(proc as Record<string, unknown>).pid = pid;
(proc as Record<string, unknown>).stdout = new EventEmitter();
(proc as Record<string, unknown>).stderr = new EventEmitter();
(proc as Record<string, unknown>).kill = vi.fn();
return proc;
}
describe('FileStreamManager', () => {
let manager: FileStreamManager;
beforeEach(() => {
vi.useFakeTimers();
manager = new FileStreamManager();
mockSpawn.mockReset();
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(statSync).mockReturnValue({ size: 1024 } as ReturnType<typeof statSync>);
});
afterEach(() => {
manager.destroy();
vi.useRealTimers();
});
// ========== Stream Creation ==========
describe('createStream', () => {
it('should create a stream successfully', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(true);
expect(result.streamId).toBeTruthy();
expect(manager.activeStreamCount).toBe(1);
});
it('should call spawn with correct arguments', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(mockSpawn).toHaveBeenCalledWith(
'tail',
['-f', '-n', '50', expect.stringContaining('/var/log/app.log')],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
});
it('should use custom lines parameter', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
lines: 100,
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(mockSpawn).toHaveBeenCalledWith(
'tail',
['-f', '-n', '100', expect.any(String)],
expect.any(Object),
);
});
it('should reject when file does not exist', async () => {
vi.mocked(existsSync).mockReturnValue(false);
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/nonexistent.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
it('should reject when file is too large', async () => {
vi.mocked(statSync).mockReturnValue({ size: 200 * 1024 * 1024 } as ReturnType<typeof statSync>);
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/huge.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/too large/i);
});
it('should reject when process fails to spawn (no PID)', async () => {
const proc = createMockProcess(0);
(proc as Record<string, unknown>).pid = undefined;
mockSpawn.mockReturnValue(proc);
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/Failed to spawn/);
});
it('should forward stdout data to onData callback', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const onData = vi.fn();
await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData,
onEnd: vi.fn(),
onError: vi.fn(),
});
proc.stdout!.emit('data', Buffer.from('line 1\nline 2\n'));
expect(onData).toHaveBeenCalledWith('line 1\nline 2\n');
});
it('should forward stderr data to onError callback', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const onError = vi.fn();
await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError,
});
proc.stderr!.emit('data', Buffer.from('tail: error\n'));
expect(onError).toHaveBeenCalledWith('tail: error\n');
});
it('should call onEnd when process exits', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const onEnd = vi.fn();
await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd,
onError: vi.fn(),
});
proc.emit('exit', 0);
expect(onEnd).toHaveBeenCalled();
});
});
// ========== Concurrent Stream Limits ==========
describe('concurrent stream limits', () => {
it('should enforce max 5 streams per session', async () => {
// Create 5 streams for same session
for (let i = 0; i < 5; i++) {
const proc = createMockProcess(1000 + i);
mockSpawn.mockReturnValue(proc);
const result = await manager.createStream({
sessionId: 'session-1',
filePath: `/var/log/file${i}.log`,
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(true);
}
// 6th should fail
const proc = createMockProcess(2000);
mockSpawn.mockReturnValue(proc);
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/file5.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/Maximum.*5.*concurrent/);
});
it('should allow streams for different sessions independently', async () => {
for (const sessionId of ['session-a', 'session-b']) {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const result = await manager.createStream({
sessionId,
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(true);
}
expect(manager.activeStreamCount).toBe(2);
});
});
// ========== Close Stream ==========
describe('closeStream', () => {
it('should close an existing stream', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
const closed = manager.closeStream(result.streamId!);
expect(closed).toBe(true);
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
expect(manager.activeStreamCount).toBe(0);
});
it('should return false for non-existent stream', () => {
expect(manager.closeStream('nonexistent')).toBe(false);
});
it('should call onEnd when stream is closed', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const onEnd = vi.fn();
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd,
onError: vi.fn(),
});
manager.closeStream(result.streamId!);
expect(onEnd).toHaveBeenCalled();
});
});
// ========== Close Session Streams ==========
describe('closeSessionStreams', () => {
it('should close all streams for a session', async () => {
const procs: ChildProcess[] = [];
for (let i = 0; i < 3; i++) {
const proc = createMockProcess(1000 + i);
procs.push(proc);
mockSpawn.mockReturnValueOnce(proc);
await manager.createStream({
sessionId: 'session-1',
filePath: `/var/log/file${i}.log`,
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
}
const closed = manager.closeSessionStreams('session-1');
expect(closed).toBe(3);
expect(manager.activeStreamCount).toBe(0);
});
it('should not affect other sessions', async () => {
for (const sessionId of ['session-1', 'session-2']) {
const proc = createMockProcess();
mockSpawn.mockReturnValueOnce(proc);
await manager.createStream({
sessionId,
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
}
manager.closeSessionStreams('session-1');
expect(manager.activeStreamCount).toBe(1);
});
});
// ========== getSessionStreams ==========
describe('getSessionStreams', () => {
it('should return stream info for a session', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
const streams = manager.getSessionStreams('session-1');
expect(streams).toHaveLength(1);
expect(streams[0].filePath).toContain('app.log');
expect(streams[0].id).toBeTruthy();
expect(streams[0].createdAt).toBeGreaterThan(0);
});
it('should return empty array for unknown session', () => {
expect(manager.getSessionStreams('unknown')).toEqual([]);
});
});
// ========== Path Validation ==========
describe('path validation', () => {
it('should reject paths outside allowed directories', async () => {
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/etc/shadow',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/Path must be within/);
});
it('should allow paths within working directory', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/sub/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(true);
});
it('should allow paths in /var/log', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/syslog',
workingDir: '/home/user',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(true);
});
it('should allow paths in /tmp', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/tmp/test.log',
workingDir: '/home/user',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(true);
});
it('should handle stat errors gracefully', async () => {
vi.mocked(statSync).mockImplementation(() => {
throw new Error('ENOENT');
});
const result = await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/gone.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/not found|not accessible/i);
});
});
// ========== Inactivity Cleanup ==========
describe('inactivity cleanup', () => {
it('should close streams inactive for 5+ minutes', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const onEnd = vi.fn();
await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd,
onError: vi.fn(),
});
// Advance past the cleanup interval (1 min) + inactivity timeout (5 min)
vi.advanceTimersByTime(6 * 60 * 1000);
expect(manager.activeStreamCount).toBe(0);
expect(onEnd).toHaveBeenCalled();
});
});
// ========== Destroy ==========
describe('destroy', () => {
it('should close all streams and clear state', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
await manager.createStream({
sessionId: 'session-1',
filePath: '/var/log/app.log',
workingDir: '/var/log',
onData: vi.fn(),
onEnd: vi.fn(),
onError: vi.fn(),
});
manager.destroy();
expect(manager.activeStreamCount).toBe(0);
});
});
});
+415
View File
@@ -0,0 +1,415 @@
/**
* @fileoverview Tests for ImageWatcher
*
* Tests image file detection, burst throttling, debounce behavior,
* session management, and cleanup.
*
* Uses mocked chokidar to avoid real filesystem watching.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { EventEmitter } from 'node:events';
// Create a mock watcher factory
const mockWatchers = new Map<string, EventEmitter>();
vi.mock('chokidar', () => ({
watch: vi.fn((path: string) => {
const watcher = new EventEmitter();
(watcher as Record<string, unknown>).close = vi.fn();
mockWatchers.set(path, watcher);
return watcher;
}),
}));
vi.mock('node:fs', async (importOriginal) => {
const orig = await importOriginal<typeof import('node:fs')>();
return {
...orig,
statSync: vi.fn(() => ({ size: 2048 })),
};
});
import { ImageWatcher } from '../src/image-watcher.js';
import { statSync } from 'node:fs';
describe('ImageWatcher', () => {
let watcher: ImageWatcher;
beforeEach(() => {
vi.useFakeTimers();
mockWatchers.clear();
watcher = new ImageWatcher();
});
afterEach(() => {
watcher.stop();
vi.useRealTimers();
});
// ========== Start / Stop ==========
describe('start / stop', () => {
it('should not be running initially', () => {
expect(watcher.isRunning()).toBe(false);
});
it('should be running after start()', () => {
watcher.start();
expect(watcher.isRunning()).toBe(true);
});
it('should not be running after stop()', () => {
watcher.start();
watcher.stop();
expect(watcher.isRunning()).toBe(false);
});
it('should be idempotent on double start', () => {
watcher.start();
watcher.start();
expect(watcher.isRunning()).toBe(true);
});
});
// ========== Session Watching ==========
describe('watchSession', () => {
it('should auto-start when watchSession is called', () => {
watcher.watchSession('session-1', '/home/user/project');
expect(watcher.isRunning()).toBe(true);
});
it('should track watched sessions', () => {
watcher.watchSession('session-1', '/home/user/project');
expect(watcher.getWatchedSessions()).toContain('session-1');
});
it('should not double-watch the same session with same dir', () => {
watcher.watchSession('session-1', '/home/user/project');
watcher.watchSession('session-1', '/home/user/project');
// Only one watcher should be created
expect(watcher.getWatchedSessions()).toHaveLength(1);
});
it('should replace watcher when working directory changes', () => {
watcher.watchSession('session-1', '/home/user/project-a');
watcher.watchSession('session-1', '/home/user/project-b');
expect(watcher.getWatchedSessions()).toHaveLength(1);
// Should have created watcher for both paths
expect(mockWatchers.has('/home/user/project-a')).toBe(true);
expect(mockWatchers.has('/home/user/project-b')).toBe(true);
});
});
// ========== Unwatch Session ==========
describe('unwatchSession', () => {
it('should remove a watched session', () => {
watcher.watchSession('session-1', '/home/user/project');
watcher.unwatchSession('session-1');
expect(watcher.getWatchedSessions()).not.toContain('session-1');
});
it('should close the chokidar watcher', () => {
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
watcher.unwatchSession('session-1');
expect(chokidarWatcher.close).toHaveBeenCalled();
});
it('should be safe to call for non-watched session', () => {
expect(() => watcher.unwatchSession('nonexistent')).not.toThrow();
});
it('should clear pending debounce timers for the session', () => {
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
// Trigger an image detection (starts debounce timer)
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
// Unwatch should clear the timer without emitting
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.unwatchSession('session-1');
// Advance past debounce period
vi.advanceTimersByTime(500);
expect(handler).not.toHaveBeenCalled();
});
});
// ========== Image Detection ==========
describe('image detection', () => {
it('should emit image:detected for .png files', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
vi.advanceTimersByTime(300); // past debounce (200ms)
expect(handler).toHaveBeenCalledTimes(1);
const event = handler.mock.calls[0][0];
expect(event.sessionId).toBe('session-1');
expect(event.fileName).toBe('screenshot.png');
expect(event.filePath).toBe('/home/user/project/screenshot.png');
expect(event.relativePath).toBe('screenshot.png');
expect(event.size).toBe(2048);
});
it('should emit for .jpg files', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
chokidarWatcher.emit('add', '/home/user/project/photo.jpg');
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(1);
});
it('should emit for .jpeg files', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/img.jpeg');
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(1);
});
it('should emit for .gif files', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/anim.gif');
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(1);
});
it('should emit for .webp files', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/img.webp');
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(1);
});
it('should emit for .svg files', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/icon.svg');
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(1);
});
it('should NOT emit for non-image files', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
chokidarWatcher.emit('add', '/home/user/project/readme.md');
vi.advanceTimersByTime(300);
expect(handler).not.toHaveBeenCalled();
});
it('should NOT emit for .ts files', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/index.ts');
vi.advanceTimersByTime(300);
expect(handler).not.toHaveBeenCalled();
});
it('should compute relative path from working directory', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/assets/img.png');
vi.advanceTimersByTime(300);
expect(handler.mock.calls[0][0].relativePath).toBe('assets/img.png');
});
});
// ========== Debounce Behavior ==========
describe('debounce', () => {
it('should debounce rapid events for the same file', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
// Rapid adds of the same file
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
vi.advanceTimersByTime(100); // not yet past debounce
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
vi.advanceTimersByTime(100);
chokidarWatcher.emit('add', '/home/user/project/screenshot.png');
vi.advanceTimersByTime(300); // now past debounce from last emit
// Should only emit once (the last debounced one)
expect(handler).toHaveBeenCalledTimes(1);
});
it('should allow events for different files concurrently', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
chokidarWatcher.emit('add', '/home/user/project/a.png');
chokidarWatcher.emit('add', '/home/user/project/b.png');
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(2);
});
});
// ========== Burst Throttling ==========
describe('burst throttling', () => {
it('should throttle after 20 images in 10 seconds', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
// Emit 25 unique images in quick succession
for (let i = 0; i < 25; i++) {
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`);
vi.advanceTimersByTime(250); // past debounce, within burst window
}
// Only 20 should get through (BURST_LIMIT = 20)
expect(handler).toHaveBeenCalledTimes(20);
});
it('should reset burst counter after window expires', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
// Fill up burst limit
for (let i = 0; i < 20; i++) {
chokidarWatcher.emit('add', `/home/user/project/img${i}.png`);
vi.advanceTimersByTime(250);
}
expect(handler).toHaveBeenCalledTimes(20);
// Wait for burst window to expire (10 seconds)
vi.advanceTimersByTime(11_000);
// Should accept new images
chokidarWatcher.emit('add', '/home/user/project/new.png');
vi.advanceTimersByTime(300);
expect(handler).toHaveBeenCalledTimes(21);
});
});
// ========== Error Handling ==========
describe('error handling', () => {
it('should emit image:error on chokidar error', () => {
const errorHandler = vi.fn();
watcher.on('image:error', errorHandler);
watcher.watchSession('session-1', '/home/user/project');
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
const testError = new Error('watch failed');
chokidarWatcher.emit('error', testError);
expect(errorHandler).toHaveBeenCalledWith(testError, 'session-1');
});
it('should emit image:error if statSync fails during emission', () => {
vi.mocked(statSync).mockImplementation(() => {
throw new Error('ENOENT');
});
const errorHandler = vi.fn();
const detectHandler = vi.fn();
watcher.on('image:error', errorHandler);
watcher.on('image:detected', detectHandler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/deleted.png');
vi.advanceTimersByTime(300);
expect(detectHandler).not.toHaveBeenCalled();
expect(errorHandler).toHaveBeenCalled();
// Restore
vi.mocked(statSync).mockReturnValue({ size: 2048 } as ReturnType<typeof statSync>);
});
});
// ========== Stop / Cleanup ==========
describe('stop', () => {
it('should close all chokidar watchers', () => {
watcher.watchSession('session-1', '/home/user/project-a');
watcher.watchSession('session-2', '/home/user/project-b');
watcher.stop();
for (const w of mockWatchers.values()) {
expect(w.close).toHaveBeenCalled();
}
});
it('should clear all maps', () => {
watcher.watchSession('session-1', '/home/user/project');
watcher.stop();
expect(watcher.getWatchedSessions()).toHaveLength(0);
expect(watcher.isRunning()).toBe(false);
});
it('should clear debounce timers so no events fire after stop', () => {
const handler = vi.fn();
watcher.on('image:detected', handler);
watcher.watchSession('session-1', '/home/user/project');
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
// Stop before debounce fires
watcher.stop();
vi.advanceTimersByTime(500);
expect(handler).not.toHaveBeenCalled();
});
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* @fileoverview Tests for nice-wrapper utility
*
* Tests the wrapWithNice function that prepends `nice -n <value>`
* to shell commands for CPU priority adjustment.
*/
import { describe, it, expect } from 'vitest';
import { wrapWithNice } from '../src/utils/nice-wrapper.js';
import type { NiceConfig } from '../src/types.js';
describe('wrapWithNice', () => {
it('should return command unchanged when disabled', () => {
const config: NiceConfig = { enabled: false, niceValue: 10 };
expect(wrapWithNice('claude --dangerously-skip-permissions', config)).toBe(
'claude --dangerously-skip-permissions',
);
});
it('should wrap command with nice when enabled', () => {
const config: NiceConfig = { enabled: true, niceValue: 10 };
expect(wrapWithNice('claude --dangerously-skip-permissions', config)).toBe(
'nice -n 10 claude --dangerously-skip-permissions',
);
});
it('should support negative nice values', () => {
const config: NiceConfig = { enabled: true, niceValue: -5 };
expect(wrapWithNice('some-cmd', config)).toBe('nice -n -5 some-cmd');
});
it('should clamp nice value to max 19', () => {
const config: NiceConfig = { enabled: true, niceValue: 50 };
expect(wrapWithNice('cmd', config)).toBe('nice -n 19 cmd');
});
it('should clamp nice value to min -20', () => {
const config: NiceConfig = { enabled: true, niceValue: -100 };
expect(wrapWithNice('cmd', config)).toBe('nice -n -20 cmd');
});
it('should handle zero nice value', () => {
const config: NiceConfig = { enabled: true, niceValue: 0 };
expect(wrapWithNice('cmd', config)).toBe('nice -n 0 cmd');
});
it('should handle boundary nice values exactly', () => {
expect(wrapWithNice('cmd', { enabled: true, niceValue: 19 })).toBe('nice -n 19 cmd');
expect(wrapWithNice('cmd', { enabled: true, niceValue: -20 })).toBe('nice -n -20 cmd');
});
it('should preserve complex commands with pipes and redirects', () => {
const config: NiceConfig = { enabled: true, niceValue: 10 };
const cmd = 'bash -c "echo hello | grep h > /tmp/out"';
expect(wrapWithNice(cmd, config)).toBe(`nice -n 10 ${cmd}`);
});
it('should handle empty command string', () => {
const config: NiceConfig = { enabled: true, niceValue: 10 };
expect(wrapWithNice('', config)).toBe('nice -n 10 ');
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
environment: 'node', environment: 'node',
include: ['test/**/*.test.ts', 'test/e2e/**/*.e2e.ts'], include: ['test/**/*.test.ts', 'test/e2e/**/*.e2e.ts'],
setupFiles: ['./test/setup.ts'], setupFiles: ['./test/setup.ts'],
// Run test files sequentially to respect screen session limits // Run test files sequentially to respect mux session limits
// Individual tests within files still run in parallel where safe // Individual tests within files still run in parallel where safe
fileParallelism: false, fileParallelism: false,
coverage: { coverage: {