mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09: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:
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Provides shared functionality for AI-powered checkers that spawn fresh Claude CLI
|
||||
* 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
|
||||
* - Polling for completion markers
|
||||
* - Cooldown management
|
||||
@@ -11,7 +11,7 @@
|
||||
* - Event emission for state changes
|
||||
*
|
||||
* 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__')
|
||||
* - `tempFilePrefix`: Prefix for temp files (e.g., 'claudeman-aicheck')
|
||||
* - `logPrefix`: Prefix for log messages (e.g., '[AiIdleChecker]')
|
||||
@@ -115,7 +115,7 @@ const POLL_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
* 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 C - The configuration type
|
||||
@@ -154,7 +154,7 @@ export abstract class AiCheckerBase<
|
||||
|
||||
// ========== 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;
|
||||
|
||||
/** Marker written to temp file when check is complete */
|
||||
@@ -318,7 +318,7 @@ export abstract class AiCheckerBase<
|
||||
|
||||
/**
|
||||
* Cancel an in-progress check.
|
||||
* Kills the screen session and cleans up.
|
||||
* Kills the mux session and cleans up.
|
||||
*/
|
||||
cancel(): void {
|
||||
if (this._status !== 'checking') return;
|
||||
|
||||
+4
-4
@@ -8,8 +8,8 @@
|
||||
* 2. **Interactive mode** (`startInteractive`): Start an interactive Claude session
|
||||
* 3. **Shell mode**: Run a plain bash shell for debugging/testing
|
||||
*
|
||||
* The session can optionally run inside a GNU Screen session for persistence
|
||||
* across disconnects. It tracks tokens, costs, background tasks, and supports
|
||||
* The session can optionally run inside a tmux session (or GNU Screen as fallback)
|
||||
* for persistence across disconnects. It tracks tokens, costs, background tasks, and supports
|
||||
* auto-clear/auto-compact functionality when token limits are approached.
|
||||
*
|
||||
* @module session
|
||||
@@ -853,7 +853,7 @@ export class Session extends EventEmitter {
|
||||
*
|
||||
* This spawns Claude CLI with `--dangerously-skip-permissions` flag in
|
||||
* 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
|
||||
*
|
||||
@@ -1997,7 +1997,7 @@ export class Session extends EventEmitter {
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param killScreen - Whether to also kill the screen session (default: true)
|
||||
|
||||
+33
-18
@@ -9,11 +9,11 @@
|
||||
*/
|
||||
|
||||
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 { 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';
|
||||
|
||||
// ========== Constants ==========
|
||||
@@ -33,7 +33,7 @@ export class TeamWatcher extends EventEmitter {
|
||||
private inboxCache: LRUMap<string, InboxMessage[]> = new LRUMap({ maxSize: MAX_CACHED_TASKS });
|
||||
// Track config mtimes to avoid re-reading unchanged files
|
||||
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();
|
||||
|
||||
constructor(teamsDir?: string, tasksDir?: string) {
|
||||
@@ -122,6 +122,19 @@ export class TeamWatcher extends EventEmitter {
|
||||
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 */
|
||||
getActiveTeammateCount(sessionId: string): number {
|
||||
const team = this.getTeamForSession(sessionId);
|
||||
@@ -142,8 +155,6 @@ export class TeamWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
private pollTeams(): void {
|
||||
if (!existsSync(this.teamsDir)) return;
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(this.teamsDir);
|
||||
@@ -155,24 +166,25 @@ export class TeamWatcher extends EventEmitter {
|
||||
|
||||
for (const entry of entries) {
|
||||
const configPath = join(this.teamsDir, entry, 'config.json');
|
||||
if (!existsSync(configPath)) continue;
|
||||
|
||||
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 {
|
||||
const mtime = statSync(configPath).mtimeMs;
|
||||
if (this.configMtimes.get(entry) === mtime) continue;
|
||||
this.configMtimes.set(entry, mtime);
|
||||
mtime = statSync(configPath).mtimeMs;
|
||||
} catch {
|
||||
// File doesn't exist or was removed between readdir and stat
|
||||
continue;
|
||||
}
|
||||
if (this.configMtimes.get(entry) === mtime) continue;
|
||||
this.configMtimes.set(entry, mtime);
|
||||
|
||||
// Skip if locked
|
||||
if (this.isLocked(join(this.teamsDir, entry, 'config.json'))) continue;
|
||||
|
||||
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);
|
||||
this.teams.set(entry, config);
|
||||
@@ -198,8 +210,6 @@ export class TeamWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
private pollTasks(): void {
|
||||
if (!existsSync(this.tasksDir)) return;
|
||||
|
||||
let teamDirs: string[];
|
||||
try {
|
||||
teamDirs = readdirSync(this.tasksDir);
|
||||
@@ -216,16 +226,22 @@ export class TeamWatcher extends EventEmitter {
|
||||
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;
|
||||
let combinedMtime = 0;
|
||||
let mtimeSum = 0;
|
||||
let mtimeMax = 0;
|
||||
let mtimeCount = 0;
|
||||
for (const f of taskFiles) {
|
||||
try {
|
||||
combinedMtime += statSync(join(teamTaskDir, f)).mtimeMs;
|
||||
const mt = statSync(join(teamTaskDir, f)).mtimeMs;
|
||||
mtimeSum += mt;
|
||||
if (mt > mtimeMax) mtimeMax = mt;
|
||||
mtimeCount++;
|
||||
} catch {
|
||||
// File may have been deleted between readdir and stat
|
||||
}
|
||||
}
|
||||
const combinedMtime = `${mtimeCount}:${mtimeMax}:${mtimeSum}`;
|
||||
if (this.taskMtimes.get(mtimeKey) === combinedMtime) continue;
|
||||
this.taskMtimes.set(mtimeKey, combinedMtime);
|
||||
|
||||
@@ -249,7 +265,6 @@ export class TeamWatcher extends EventEmitter {
|
||||
// Inbox files live under ~/.claude/teams/{name}/inboxes/
|
||||
for (const [teamName] of this.teams.entries()) {
|
||||
const inboxDir = join(this.teamsDir, teamName, 'inboxes');
|
||||
if (!existsSync(inboxDir)) continue;
|
||||
|
||||
let inboxFiles: string[];
|
||||
try {
|
||||
@@ -296,7 +311,7 @@ export class TeamWatcher extends EventEmitter {
|
||||
private isLocked(path: string): boolean {
|
||||
const lockDir = `${path}.lock`;
|
||||
try {
|
||||
return existsSync(lockDir) && statSync(lockDir).isDirectory();
|
||||
return statSync(lockDir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ Then update the Project Overview section below.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
- 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 { dirname, join } from 'node:path';
|
||||
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 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) */
|
||||
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.
|
||||
* 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
@@ -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 {
|
||||
return 'tmux';
|
||||
}
|
||||
|
||||
+22
-3
@@ -855,9 +855,9 @@ export interface CaseInfo {
|
||||
// ========== 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.
|
||||
@@ -913,7 +913,7 @@ export interface PersistedRespawnConfig {
|
||||
export interface ScreenSession {
|
||||
/** Claudeman session ID */
|
||||
sessionId: string;
|
||||
/** GNU screen session name (claudeman-<id>) */
|
||||
/** Mux session name (claudeman-<id>) */
|
||||
screenName: string;
|
||||
/** Screen process PID */
|
||||
pid: number;
|
||||
@@ -1514,6 +1514,25 @@ export interface ImageDetectedEvent {
|
||||
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 ==========
|
||||
|
||||
/**
|
||||
|
||||
+371
-10
@@ -1149,6 +1149,10 @@ class ClaudemanApp {
|
||||
this.teams = new Map(); // Map<teamName, TeamConfig>
|
||||
this.teamTasks = new Map(); // Map<teamName, TeamTask[]>
|
||||
this.teammateMap = new Map(); // Map<agentId-prefix, {name, color, teamName}> for quick lookup
|
||||
|
||||
// Teammate tmux pane terminals
|
||||
this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }>
|
||||
this.teammateTerminals = new Map(); // Map<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }>
|
||||
this.ralphStatePanelCollapsed = true; // Default to collapsed
|
||||
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
|
||||
|
||||
@@ -2103,6 +2107,23 @@ class ClaudemanApp {
|
||||
this.closeSessionLogViewerWindows(data.id); // Close log viewer 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
|
||||
|
||||
// Clean up any agent teams associated with this session
|
||||
for (const [teamName, team] of this.teams) {
|
||||
if (team.leadSessionId === data.id) {
|
||||
this.teams.delete(teamName);
|
||||
this.teamTasks.delete(teamName);
|
||||
}
|
||||
}
|
||||
// Clean up teammate pane tracking for this session
|
||||
for (const [name, paneInfo] of this.teammatePanesByName) {
|
||||
if (paneInfo.sessionId === data.id) {
|
||||
this.teammatePanesByName.delete(name);
|
||||
}
|
||||
}
|
||||
this.rebuildTeammateMap();
|
||||
this.renderTeamTasksPanel();
|
||||
|
||||
// Clean up idle timer for this session
|
||||
const idleTimer = this.idleTimers.get(data.id);
|
||||
if (idleTimer) {
|
||||
@@ -2531,7 +2552,7 @@ class ClaudemanApp {
|
||||
const data = JSON.parse(e.data);
|
||||
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId);
|
||||
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) => {
|
||||
@@ -2736,9 +2757,15 @@ class ClaudemanApp {
|
||||
// Find which Claudeman session owns this subagent (direct claudeSessionId match only)
|
||||
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') {
|
||||
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
|
||||
@@ -2903,6 +2930,57 @@ class ClaudemanApp {
|
||||
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) ==========
|
||||
|
||||
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() {
|
||||
const panel = document.getElementById('monitorPanel');
|
||||
@@ -11972,6 +12050,13 @@ class ClaudemanApp {
|
||||
const agent = this.subagents.get(agentId);
|
||||
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
|
||||
const windowCount = this.subagentWindows.size;
|
||||
const windowWidth = 420;
|
||||
@@ -12133,8 +12218,13 @@ class ClaudemanApp {
|
||||
win.style.display = 'none';
|
||||
}
|
||||
|
||||
// Render content
|
||||
this.renderSubagentWindowContent(agentId);
|
||||
// Render content — check if this teammate has a tmux pane
|
||||
const paneInfo = teammateInfo ? this.teammatePanesByName.get(teammateInfo.name) : null;
|
||||
if (paneInfo) {
|
||||
this.initTeammateTerminal(agentId, paneInfo, win);
|
||||
} else {
|
||||
this.renderSubagentWindowContent(agentId);
|
||||
}
|
||||
|
||||
// Focus on click
|
||||
win.addEventListener('mousedown', () => {
|
||||
@@ -12217,7 +12307,10 @@ class ClaudemanApp {
|
||||
const toClose = [];
|
||||
for (const [agentId, _windowData] of this.subagentWindows) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -12228,6 +12321,7 @@ class ClaudemanApp {
|
||||
this.subagents.delete(agentId);
|
||||
this.subagentActivity.delete(agentId);
|
||||
this.subagentToolResults.delete(agentId);
|
||||
this.subagentParentMap.delete(agentId);
|
||||
}
|
||||
}
|
||||
// Also clean up minimized agents for this session
|
||||
@@ -12251,6 +12345,17 @@ class ClaudemanApp {
|
||||
windowData.element.remove();
|
||||
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)
|
||||
@@ -12268,6 +12373,16 @@ class ClaudemanApp {
|
||||
}
|
||||
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
|
||||
for (const [windowId, data] of this.logViewerWindows) {
|
||||
if (data.eventSource) {
|
||||
@@ -12460,6 +12575,9 @@ class ClaudemanApp {
|
||||
}
|
||||
|
||||
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}`);
|
||||
if (!body) return;
|
||||
|
||||
@@ -12593,6 +12711,249 @@ class ClaudemanApp {
|
||||
|
||||
// ========== 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 */
|
||||
rebuildTeammateMap() {
|
||||
this.teammateMap.clear();
|
||||
@@ -13625,7 +13986,7 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Screen Sessions (in Monitor Panel) ==========
|
||||
// ========== Mux Sessions (in Monitor Panel) ==========
|
||||
|
||||
async loadScreens() {
|
||||
try {
|
||||
@@ -13974,7 +14335,7 @@ class ClaudemanApp {
|
||||
const body = document.getElementById('screenSessionsBody');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -14049,7 +14410,7 @@ class ClaudemanApp {
|
||||
}
|
||||
|
||||
async killScreen(sessionId) {
|
||||
if (!confirm('Kill this screen session?')) return;
|
||||
if (!confirm('Kill this mux session?')) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/screens/${sessionId}`, { method: 'DELETE' });
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
<div class="welcome-overlay" id="welcomeOverlay">
|
||||
<div class="welcome-content">
|
||||
<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>
|
||||
<div class="welcome-actions">
|
||||
<button class="welcome-btn welcome-btn-primary" onclick="app.runClaude()">
|
||||
|
||||
@@ -5317,6 +5317,20 @@ kbd {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Teammate terminal pane (xterm.js embedded in subagent window) */
|
||||
.subagent-window-body.teammate-terminal-body {
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.subagent-window-body.teammate-terminal-body .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
/* Larger default size for terminal windows */
|
||||
.subagent-window.has-terminal {
|
||||
width: 550px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for subagent window body */
|
||||
.subagent-window-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
+12
-1
@@ -79,7 +79,7 @@ export const QuickStartSchema = z.object({
|
||||
export const HookEventSchema = z.object({
|
||||
event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop', 'teammate_idle', 'task_completed']),
|
||||
sessionId: z.string().min(1),
|
||||
data: z.unknown().optional(),
|
||||
data: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// ========== Configuration ==========
|
||||
@@ -140,3 +140,14 @@ export const SessionInputWithLimitSchema = z.object({
|
||||
input: z.string().max(100000), // 100KB max input
|
||||
useScreen: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ========== Teammate Pane Routes ==========
|
||||
|
||||
/**
|
||||
* Schema for POST /api/sessions/:id/teammate-pane-input
|
||||
* Sends keyboard input to a teammate's tmux pane.
|
||||
*/
|
||||
export const TeammatePaneInputSchema = z.object({
|
||||
paneTarget: z.string().regex(/^(%\d+|\d+)$/, 'Invalid pane target format'),
|
||||
input: z.string().max(100000),
|
||||
});
|
||||
|
||||
+251
-18
@@ -14,15 +14,16 @@ import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import path, { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
||||
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 { execSync } from 'node:child_process';
|
||||
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||
import { execSync, spawn, type ChildProcess } from 'node:child_process';
|
||||
import { homedir, tmpdir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool } from '../session.js';
|
||||
import { fileStreamManager } from '../file-stream-manager.js';
|
||||
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
|
||||
import type { TerminalMultiplexer } from '../mux-interface.js';
|
||||
import { TmuxManager } from '../tmux-manager.js';
|
||||
import { createMultiplexer } from '../mux-factory.js';
|
||||
import { getStore } from '../state-store.js';
|
||||
import { generateClaudeMd } from '../templates/claude-md.js';
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
HookEventSchema,
|
||||
ConfigUpdateSchema,
|
||||
RespawnConfigSchema,
|
||||
TeammatePaneInputSchema,
|
||||
} from './schemas.js';
|
||||
import { StaleExpirationMap } from '../utils/index.js';
|
||||
|
||||
@@ -376,6 +378,9 @@ export class WebServer extends EventEmitter {
|
||||
error: (error: Error, sessionId?: string) => void;
|
||||
} | null = null;
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
// Pane streaming for teammate tmux panes: keyed by "sessionId:paneTarget"
|
||||
private paneStreams: Map<string, { tailProcess: ChildProcess; outputFile: string; muxName: string; paneTarget: string; sessionId: string; teammateName: string }> = new Map();
|
||||
private paneStreamTempDir: string | null = null;
|
||||
|
||||
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
|
||||
super();
|
||||
@@ -502,12 +507,15 @@ export class WebServer extends EventEmitter {
|
||||
private setupTeamWatcherListeners(): void {
|
||||
this.teamWatcher.on('teamCreated', (team: TeamConfig) => {
|
||||
this.broadcast('team:created', team);
|
||||
this.startPaneStreamsForTeam(team);
|
||||
});
|
||||
this.teamWatcher.on('teamUpdated', (team: TeamConfig) => {
|
||||
this.broadcast('team:updated', team);
|
||||
this.startPaneStreamsForTeam(team);
|
||||
});
|
||||
this.teamWatcher.on('teamRemoved', (team: TeamConfig) => {
|
||||
this.broadcast('team:removed', team);
|
||||
this.stopPaneStreamsForSession(team.leadSessionId);
|
||||
});
|
||||
this.teamWatcher.on('taskUpdated', (data: { teamName: string; tasks: TeamTask[] }) => {
|
||||
// Filter out internal tasks for broadcast
|
||||
@@ -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> {
|
||||
// Serve static files
|
||||
await this.app.register(fastifyStatic, {
|
||||
@@ -542,6 +697,18 @@ export class WebServer extends EventEmitter {
|
||||
// Buffers are fetched on-demand when switching tabs
|
||||
this.sendSSE(reply, 'init', this.getLightState());
|
||||
|
||||
// Send pane_available events for any active pane streams
|
||||
for (const stream of this.paneStreams.values()) {
|
||||
const team = this.teamWatcher?.getTeamForSession(stream.sessionId);
|
||||
const member = team?.members.find(m => m.name === stream.teammateName);
|
||||
this.sendSSE(reply, 'teammate:pane_available', {
|
||||
sessionId: stream.sessionId,
|
||||
teammateName: stream.teammateName,
|
||||
paneTarget: stream.paneTarget,
|
||||
color: member?.color || 'blue',
|
||||
});
|
||||
}
|
||||
|
||||
req.raw.on('close', () => {
|
||||
this.sseClients.delete(reply);
|
||||
});
|
||||
@@ -1548,7 +1715,7 @@ export class WebServer extends EventEmitter {
|
||||
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;
|
||||
if (useScreen) {
|
||||
success = session.writeViaScreen(inputStr);
|
||||
@@ -1697,7 +1864,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
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.persistSessionState(session);
|
||||
|
||||
@@ -1888,7 +2055,7 @@ export class WebServer extends EventEmitter {
|
||||
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.persistSessionState(session);
|
||||
|
||||
@@ -3155,7 +3322,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
return {
|
||||
success: true,
|
||||
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) => {
|
||||
const { sessionId } = req.params as { sessionId: string };
|
||||
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 };
|
||||
});
|
||||
|
||||
// ========== Teammate Pane Endpoints ==========
|
||||
|
||||
// List teammate panes for a session
|
||||
this.app.get('/api/sessions/:id/teammate-panes', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const team = this.teamWatcher.getTeamForSession(id);
|
||||
if (!team) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const paneTeammates = this.teamWatcher.getTmuxPaneTeammates(team.name);
|
||||
const panes = paneTeammates.map(t => ({
|
||||
teammateName: t.name,
|
||||
paneTarget: t.tmuxPaneId,
|
||||
color: t.color || 'blue',
|
||||
hasStream: this.paneStreams.has(`${id}:${t.tmuxPaneId}`),
|
||||
}));
|
||||
return { success: true, data: panes };
|
||||
});
|
||||
|
||||
// Get pane buffer for a teammate
|
||||
this.app.get('/api/sessions/:id/teammate-pane-buffer/:paneTarget', async (req) => {
|
||||
const { id, paneTarget } = req.params as { id: string; paneTarget: string };
|
||||
if (this.mux.backend !== 'tmux') {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Pane access requires tmux backend');
|
||||
}
|
||||
const tmuxMgr = this.mux as TmuxManager;
|
||||
const muxSession = tmuxMgr.getSession(id);
|
||||
if (!muxSession) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const buffer = tmuxMgr.capturePaneBuffer(muxSession.muxName, paneTarget);
|
||||
if (buffer === null) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to capture pane buffer');
|
||||
}
|
||||
return { success: true, data: { buffer } };
|
||||
});
|
||||
|
||||
// Send input to a teammate's pane
|
||||
this.app.post('/api/sessions/:id/teammate-pane-input', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = TeammatePaneInputSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
|
||||
}
|
||||
const { paneTarget, input } = result.data;
|
||||
if (this.mux.backend !== 'tmux') {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Pane access requires tmux backend');
|
||||
}
|
||||
const tmuxMgr = this.mux as TmuxManager;
|
||||
const muxSession = tmuxMgr.getSession(id);
|
||||
if (!muxSession) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
const success = tmuxMgr.sendInputToPane(muxSession.muxName, paneTarget, input);
|
||||
return { success };
|
||||
});
|
||||
|
||||
// ========== Hook Events ==========
|
||||
|
||||
this.app.post('/api/hook-event', async (req) => {
|
||||
@@ -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
|
||||
if (data && typeof data === 'object' && 'transcript_path' in data) {
|
||||
if (data && 'transcript_path' in data) {
|
||||
const transcriptPath = String(data.transcript_path);
|
||||
if (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
|
||||
const safeData = sanitizeHookData(data as Record<string, unknown> | undefined);
|
||||
const safeData = sanitizeHookData(data);
|
||||
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const persistedConfig: PersistedRespawnConfig = {
|
||||
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
|
||||
const controller = this.respawnControllers.get(sessionId);
|
||||
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) {
|
||||
try {
|
||||
// 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) {
|
||||
deadClients.push(client);
|
||||
} else {
|
||||
@@ -4757,7 +4989,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
async start(): Promise<void> {
|
||||
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
|
||||
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
|
||||
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();
|
||||
|
||||
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) {
|
||||
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
|
||||
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) {
|
||||
console.log(`[Server] Cleaned up ${dead.length} dead screen session(s)`);
|
||||
console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`);
|
||||
}
|
||||
} 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
|
||||
imageWatcher.stop();
|
||||
|
||||
// Stop team watcher
|
||||
// Stop team watcher and clean up pane streams
|
||||
this.cleanupAllPaneStreams();
|
||||
this.teamWatcher.removeAllListeners();
|
||||
this.teamWatcher.stop();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user