mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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' });
|
||||||
|
|||||||
@@ -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()">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user