aicodeman 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/ai-checker-base.d.ts +175 -0
- package/dist/ai-checker-base.d.ts.map +1 -0
- package/dist/ai-checker-base.js +424 -0
- package/dist/ai-checker-base.js.map +1 -0
- package/dist/ai-idle-checker.d.ts +53 -0
- package/dist/ai-idle-checker.d.ts.map +1 -0
- package/dist/ai-idle-checker.js +141 -0
- package/dist/ai-idle-checker.js.map +1 -0
- package/dist/ai-plan-checker.d.ts +52 -0
- package/dist/ai-plan-checker.d.ts.map +1 -0
- package/dist/ai-plan-checker.js +103 -0
- package/dist/ai-plan-checker.js.map +1 -0
- package/dist/bash-tool-parser.d.ts +191 -0
- package/dist/bash-tool-parser.d.ts.map +1 -0
- package/dist/bash-tool-parser.js +598 -0
- package/dist/bash-tool-parser.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +460 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/buffer-limits.d.ts +59 -0
- package/dist/config/buffer-limits.d.ts.map +1 -0
- package/dist/config/buffer-limits.js +74 -0
- package/dist/config/buffer-limits.js.map +1 -0
- package/dist/config/map-limits.d.ts +40 -0
- package/dist/config/map-limits.d.ts.map +1 -0
- package/dist/config/map-limits.js +52 -0
- package/dist/config/map-limits.js.map +1 -0
- package/dist/file-stream-manager.d.ts +148 -0
- package/dist/file-stream-manager.d.ts.map +1 -0
- package/dist/file-stream-manager.js +351 -0
- package/dist/file-stream-manager.js.map +1 -0
- package/dist/hooks-config.d.ts +31 -0
- package/dist/hooks-config.d.ts.map +1 -0
- package/dist/hooks-config.js +115 -0
- package/dist/hooks-config.js.map +1 -0
- package/dist/image-watcher.d.ts +86 -0
- package/dist/image-watcher.d.ts.map +1 -0
- package/dist/image-watcher.js +275 -0
- package/dist/image-watcher.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/mux-factory.d.ts +13 -0
- package/dist/mux-factory.d.ts.map +1 -0
- package/dist/mux-factory.js +19 -0
- package/dist/mux-factory.js.map +1 -0
- package/dist/mux-interface.d.ts +145 -0
- package/dist/mux-interface.d.ts.map +1 -0
- package/dist/mux-interface.js +9 -0
- package/dist/mux-interface.js.map +1 -0
- package/dist/plan-orchestrator.d.ts +123 -0
- package/dist/plan-orchestrator.d.ts.map +1 -0
- package/dist/plan-orchestrator.js +500 -0
- package/dist/plan-orchestrator.js.map +1 -0
- package/dist/prompts/index.d.ts +9 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +9 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/prompts/planner.d.ts +14 -0
- package/dist/prompts/planner.d.ts.map +1 -0
- package/dist/prompts/planner.js +83 -0
- package/dist/prompts/planner.js.map +1 -0
- package/dist/prompts/research-agent.d.ts +10 -0
- package/dist/prompts/research-agent.d.ts.map +1 -0
- package/dist/prompts/research-agent.js +143 -0
- package/dist/prompts/research-agent.js.map +1 -0
- package/dist/push-store.d.ts +41 -0
- package/dist/push-store.d.ts.map +1 -0
- package/dist/push-store.js +168 -0
- package/dist/push-store.js.map +1 -0
- package/dist/ralph-config.d.ts +67 -0
- package/dist/ralph-config.d.ts.map +1 -0
- package/dist/ralph-config.js +134 -0
- package/dist/ralph-config.js.map +1 -0
- package/dist/ralph-loop.d.ts +124 -0
- package/dist/ralph-loop.d.ts.map +1 -0
- package/dist/ralph-loop.js +418 -0
- package/dist/ralph-loop.js.map +1 -0
- package/dist/ralph-tracker.d.ts +1081 -0
- package/dist/ralph-tracker.d.ts.map +1 -0
- package/dist/ralph-tracker.js +3343 -0
- package/dist/ralph-tracker.js.map +1 -0
- package/dist/respawn-controller.d.ts +1182 -0
- package/dist/respawn-controller.d.ts.map +1 -0
- package/dist/respawn-controller.js +2754 -0
- package/dist/respawn-controller.js.map +1 -0
- package/dist/run-summary.d.ts +123 -0
- package/dist/run-summary.d.ts.map +1 -0
- package/dist/run-summary.js +325 -0
- package/dist/run-summary.js.map +1 -0
- package/dist/session-lifecycle-log.d.ts +36 -0
- package/dist/session-lifecycle-log.d.ts.map +1 -0
- package/dist/session-lifecycle-log.js +101 -0
- package/dist/session-lifecycle-log.js.map +1 -0
- package/dist/session-manager.d.ts +97 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +224 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/session.d.ts +686 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +2025 -0
- package/dist/session.js.map +1 -0
- package/dist/state-store.d.ts +189 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +730 -0
- package/dist/state-store.js.map +1 -0
- package/dist/subagent-watcher.d.ts +345 -0
- package/dist/subagent-watcher.d.ts.map +1 -0
- package/dist/subagent-watcher.js +1469 -0
- package/dist/subagent-watcher.js.map +1 -0
- package/dist/task-queue.d.ts +108 -0
- package/dist/task-queue.d.ts.map +1 -0
- package/dist/task-queue.js +235 -0
- package/dist/task-queue.js.map +1 -0
- package/dist/task-tracker.d.ts +306 -0
- package/dist/task-tracker.d.ts.map +1 -0
- package/dist/task-tracker.js +488 -0
- package/dist/task-tracker.js.map +1 -0
- package/dist/task.d.ts +73 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +177 -0
- package/dist/task.js.map +1 -0
- package/dist/team-watcher.d.ts +53 -0
- package/dist/team-watcher.d.ts.map +1 -0
- package/dist/team-watcher.js +313 -0
- package/dist/team-watcher.js.map +1 -0
- package/dist/templates/case-template.md +461 -0
- package/dist/templates/claude-md.d.ts +26 -0
- package/dist/templates/claude-md.d.ts.map +1 -0
- package/dist/templates/claude-md.js +74 -0
- package/dist/templates/claude-md.js.map +1 -0
- package/dist/tmux-manager.d.ts +181 -0
- package/dist/tmux-manager.d.ts.map +1 -0
- package/dist/tmux-manager.js +1405 -0
- package/dist/tmux-manager.js.map +1 -0
- package/dist/transcript-watcher.d.ts +110 -0
- package/dist/transcript-watcher.d.ts.map +1 -0
- package/dist/transcript-watcher.js +338 -0
- package/dist/transcript-watcher.js.map +1 -0
- package/dist/tunnel-manager.d.ts +54 -0
- package/dist/tunnel-manager.d.ts.map +1 -0
- package/dist/tunnel-manager.js +251 -0
- package/dist/tunnel-manager.js.map +1 -0
- package/dist/types.d.ts +1139 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +215 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/buffer-accumulator.d.ts +111 -0
- package/dist/utils/buffer-accumulator.d.ts.map +1 -0
- package/dist/utils/buffer-accumulator.js +172 -0
- package/dist/utils/buffer-accumulator.js.map +1 -0
- package/dist/utils/claude-cli-resolver.d.ts +26 -0
- package/dist/utils/claude-cli-resolver.d.ts.map +1 -0
- package/dist/utils/claude-cli-resolver.js +78 -0
- package/dist/utils/claude-cli-resolver.js.map +1 -0
- package/dist/utils/cleanup-manager.d.ts +165 -0
- package/dist/utils/cleanup-manager.d.ts.map +1 -0
- package/dist/utils/cleanup-manager.js +274 -0
- package/dist/utils/cleanup-manager.js.map +1 -0
- package/dist/utils/index.d.ts +19 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +19 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/lru-map.d.ts +140 -0
- package/dist/utils/lru-map.d.ts.map +1 -0
- package/dist/utils/lru-map.js +234 -0
- package/dist/utils/lru-map.js.map +1 -0
- package/dist/utils/nice-wrapper.d.ts +13 -0
- package/dist/utils/nice-wrapper.d.ts.map +1 -0
- package/dist/utils/nice-wrapper.js +17 -0
- package/dist/utils/nice-wrapper.js.map +1 -0
- package/dist/utils/opencode-cli-resolver.d.ts +21 -0
- package/dist/utils/opencode-cli-resolver.d.ts.map +1 -0
- package/dist/utils/opencode-cli-resolver.js +67 -0
- package/dist/utils/opencode-cli-resolver.js.map +1 -0
- package/dist/utils/regex-patterns.d.ts +64 -0
- package/dist/utils/regex-patterns.d.ts.map +1 -0
- package/dist/utils/regex-patterns.js +74 -0
- package/dist/utils/regex-patterns.js.map +1 -0
- package/dist/utils/stale-expiration-map.d.ts +159 -0
- package/dist/utils/stale-expiration-map.d.ts.map +1 -0
- package/dist/utils/stale-expiration-map.js +277 -0
- package/dist/utils/stale-expiration-map.js.map +1 -0
- package/dist/utils/string-similarity.d.ts +108 -0
- package/dist/utils/string-similarity.d.ts.map +1 -0
- package/dist/utils/string-similarity.js +189 -0
- package/dist/utils/string-similarity.js.map +1 -0
- package/dist/utils/token-validation.d.ts +39 -0
- package/dist/utils/token-validation.d.ts.map +1 -0
- package/dist/utils/token-validation.js +59 -0
- package/dist/utils/token-validation.js.map +1 -0
- package/dist/utils/type-safety.d.ts +33 -0
- package/dist/utils/type-safety.d.ts.map +1 -0
- package/dist/utils/type-safety.js +35 -0
- package/dist/utils/type-safety.js.map +1 -0
- package/dist/web/public/app.js +491 -0
- package/dist/web/public/app.js.br +0 -0
- package/dist/web/public/app.js.gz +0 -0
- package/dist/web/public/index.html +1675 -0
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.json +8 -0
- package/dist/web/public/mobile.css +1 -0
- package/dist/web/public/mobile.css.br +0 -0
- package/dist/web/public/mobile.css.gz +0 -0
- package/dist/web/public/ralph-wizard.js +1037 -0
- package/dist/web/public/ralph-wizard.js.br +0 -0
- package/dist/web/public/ralph-wizard.js.gz +0 -0
- package/dist/web/public/styles.css +1 -0
- package/dist/web/public/styles.css.br +0 -0
- package/dist/web/public/styles.css.gz +0 -0
- package/dist/web/public/sw.js +67 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/upload.html +155 -0
- package/dist/web/public/upload.html.br +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js +1 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js +1 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js +2 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css +209 -0
- package/dist/web/public/vendor/xterm.css.br +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js +9 -0
- package/dist/web/public/vendor/xterm.min.js.br +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/schemas.d.ts +479 -0
- package/dist/web/schemas.d.ts.map +1 -0
- package/dist/web/schemas.js +448 -0
- package/dist/web/schemas.js.map +1 -0
- package/dist/web/server.d.ts +207 -0
- package/dist/web/server.d.ts.map +1 -0
- package/dist/web/server.js +5784 -0
- package/dist/web/server.js.map +1 -0
- package/package.json +110 -0
- package/scripts/postinstall.js +390 -0
|
@@ -0,0 +1,1405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview tmux session manager for persistent Claude sessions.
|
|
3
|
+
*
|
|
4
|
+
* This module provides the TmuxManager class which creates and manages
|
|
5
|
+
* tmux sessions that wrap Claude CLI processes. tmux provides:
|
|
6
|
+
*
|
|
7
|
+
* - **Persistence**: Sessions survive server restarts and disconnects
|
|
8
|
+
* - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup
|
|
9
|
+
* - **Resource tracking**: Memory, CPU, and child process stats per session
|
|
10
|
+
* - **Reliable input**: `send-keys -l` sends literal text in a single command
|
|
11
|
+
* - **Teammate support**: Immutable pane IDs enable targeting individual teammates
|
|
12
|
+
*
|
|
13
|
+
* tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json.
|
|
14
|
+
*
|
|
15
|
+
* Key features:
|
|
16
|
+
* - `send-keys 'text' Enter` sends literal text in a single command
|
|
17
|
+
* - `list-sessions -F` provides structured queries
|
|
18
|
+
* - `display-message -p '#{pane_pid}'` for reliable PID discovery
|
|
19
|
+
* - Single server architecture
|
|
20
|
+
*
|
|
21
|
+
* @module tmux-manager
|
|
22
|
+
*/
|
|
23
|
+
import { EventEmitter } from 'node:events';
|
|
24
|
+
import { execSync, exec } from 'node:child_process';
|
|
25
|
+
import { promisify } from 'node:util';
|
|
26
|
+
const execAsync = promisify(exec);
|
|
27
|
+
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
|
28
|
+
import { writeFile, rename } from 'node:fs/promises';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
import { homedir } from 'node:os';
|
|
31
|
+
import { getErrorMessage, DEFAULT_NICE_CONFIG, } from './types.js';
|
|
32
|
+
import { wrapWithNice } from './utils/nice-wrapper.js';
|
|
33
|
+
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
|
|
34
|
+
// Claude CLI PATH resolution — shared utility
|
|
35
|
+
import { findClaudeDir } from './utils/claude-cli-resolver.js';
|
|
36
|
+
// OpenCode CLI PATH resolution
|
|
37
|
+
import { resolveOpenCodeDir } from './utils/opencode-cli-resolver.js';
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Timing Constants
|
|
40
|
+
// ============================================================================
|
|
41
|
+
/** Timeout for exec commands (5 seconds) */
|
|
42
|
+
const EXEC_TIMEOUT_MS = 5000;
|
|
43
|
+
/** Delay after tmux session creation — enough for detached tmux to be queryable */
|
|
44
|
+
const TMUX_CREATION_WAIT_MS = 100;
|
|
45
|
+
/** Max retries for getPanePid — tmux server cold-start (e.g. macOS) may need extra time */
|
|
46
|
+
const GET_PID_MAX_RETRIES = 5;
|
|
47
|
+
const GET_PID_RETRY_MS = 200;
|
|
48
|
+
/** Delay after tmux kill command (200ms) */
|
|
49
|
+
const TMUX_KILL_WAIT_MS = 200;
|
|
50
|
+
/** Delay for graceful shutdown (100ms) */
|
|
51
|
+
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
|
52
|
+
/** Default stats collection interval (2 seconds) */
|
|
53
|
+
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
|
54
|
+
/**
|
|
55
|
+
* SAFETY: Test mode detection.
|
|
56
|
+
* When running under vitest (VITEST env var is set automatically),
|
|
57
|
+
* ALL tmux shell commands are disabled. TmuxManager becomes a pure
|
|
58
|
+
* in-memory mock that cannot interact with real tmux sessions.
|
|
59
|
+
*
|
|
60
|
+
* This makes it PHYSICALLY IMPOSSIBLE for any test to:
|
|
61
|
+
* - Kill a tmux session
|
|
62
|
+
* - Create a tmux session
|
|
63
|
+
* - Send input to a tmux session
|
|
64
|
+
* - Discover/reconcile real tmux sessions
|
|
65
|
+
* - Read/write ~/.codeman/mux-sessions.json
|
|
66
|
+
*/
|
|
67
|
+
const IS_TEST_MODE = !!process.env.VITEST;
|
|
68
|
+
/** Path to persisted mux session metadata */
|
|
69
|
+
const MUX_SESSIONS_FILE = join(homedir(), '.codeman', 'mux-sessions.json');
|
|
70
|
+
/** Regex to validate tmux session names (only allow safe characters) */
|
|
71
|
+
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
|
|
72
|
+
/** Legacy pattern for pre-rename sessions (claudeman- prefix) */
|
|
73
|
+
const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
|
|
74
|
+
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
|
|
75
|
+
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
|
|
76
|
+
/**
|
|
77
|
+
* Validates that a session name contains only safe characters.
|
|
78
|
+
* Prevents command injection via malformed session IDs.
|
|
79
|
+
*/
|
|
80
|
+
function isValidMuxName(name) {
|
|
81
|
+
return SAFE_MUX_NAME_PATTERN.test(name) || LEGACY_MUX_NAME_PATTERN.test(name);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Validates that a path contains only safe characters.
|
|
85
|
+
* Prevents command injection via malformed paths.
|
|
86
|
+
*/
|
|
87
|
+
function isValidPath(path) {
|
|
88
|
+
if (path.includes(';') ||
|
|
89
|
+
path.includes('&') ||
|
|
90
|
+
path.includes('|') ||
|
|
91
|
+
path.includes('$') ||
|
|
92
|
+
path.includes('`') ||
|
|
93
|
+
path.includes('(') ||
|
|
94
|
+
path.includes(')') ||
|
|
95
|
+
path.includes('{') ||
|
|
96
|
+
path.includes('}') ||
|
|
97
|
+
path.includes('<') ||
|
|
98
|
+
path.includes('>') ||
|
|
99
|
+
path.includes("'") ||
|
|
100
|
+
path.includes('"') ||
|
|
101
|
+
path.includes('\n') ||
|
|
102
|
+
path.includes('\r')) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (path.includes('..')) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
return SAFE_PATH_PATTERN.test(path);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Build Claude CLI permission flags for the tmux command string.
|
|
112
|
+
* Validates allowedTools to prevent command injection.
|
|
113
|
+
*/
|
|
114
|
+
function buildClaudePermissionFlags(claudeMode, allowedTools) {
|
|
115
|
+
const mode = claudeMode || 'dangerously-skip-permissions';
|
|
116
|
+
switch (mode) {
|
|
117
|
+
case 'dangerously-skip-permissions':
|
|
118
|
+
return ' --dangerously-skip-permissions';
|
|
119
|
+
case 'allowedTools':
|
|
120
|
+
if (allowedTools) {
|
|
121
|
+
// Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated
|
|
122
|
+
// Block shell metacharacters: ; & | $ ` \ { } < > ' " newlines
|
|
123
|
+
const hasDangerousChars = /[;&|$`\\{}<>'"[\]\n\r]/.test(allowedTools);
|
|
124
|
+
if (!hasDangerousChars) {
|
|
125
|
+
return ` --allowedTools "${allowedTools}"`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Fall back to normal mode if tools are invalid or missing
|
|
129
|
+
return '';
|
|
130
|
+
case 'normal':
|
|
131
|
+
return '';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Build the opencode CLI command with appropriate flags.
|
|
136
|
+
*/
|
|
137
|
+
function buildOpenCodeCommand(config) {
|
|
138
|
+
const parts = ['opencode'];
|
|
139
|
+
// Model selection — allow provider/model format (alphanumeric, dots, hyphens, slashes)
|
|
140
|
+
if (config?.model) {
|
|
141
|
+
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
|
142
|
+
if (safeModel)
|
|
143
|
+
parts.push('--model', safeModel);
|
|
144
|
+
}
|
|
145
|
+
// Continue existing session
|
|
146
|
+
if (config?.continueSession) {
|
|
147
|
+
const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) ? config.continueSession : undefined;
|
|
148
|
+
if (safeId)
|
|
149
|
+
parts.push('--session', safeId);
|
|
150
|
+
if (safeId && config.forkSession)
|
|
151
|
+
parts.push('--fork');
|
|
152
|
+
}
|
|
153
|
+
return parts.join(' ');
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Build the spawn command for any session mode.
|
|
157
|
+
* Shared by createSession() and respawnPane() to avoid duplication.
|
|
158
|
+
*/
|
|
159
|
+
function buildSpawnCommand(options) {
|
|
160
|
+
if (options.mode === 'claude') {
|
|
161
|
+
// Validate model to prevent command injection
|
|
162
|
+
const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined;
|
|
163
|
+
const modelFlag = safeModel ? ` --model ${safeModel}` : '';
|
|
164
|
+
return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`;
|
|
165
|
+
}
|
|
166
|
+
if (options.mode === 'opencode') {
|
|
167
|
+
return buildOpenCodeCommand(options.openCodeConfig);
|
|
168
|
+
}
|
|
169
|
+
return '$SHELL';
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Set sensitive environment variables on a tmux session via setenv.
|
|
173
|
+
* These are inherited by panes but not visible in ps output or tmux history.
|
|
174
|
+
*/
|
|
175
|
+
function setOpenCodeEnvVars(muxName) {
|
|
176
|
+
const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
|
|
177
|
+
for (const key of sensitiveVars) {
|
|
178
|
+
const val = process.env[key];
|
|
179
|
+
if (val) {
|
|
180
|
+
// Shell-escape: wrap in single quotes, escape any inner single quotes
|
|
181
|
+
const escaped = val.replace(/'/g, "'\\''");
|
|
182
|
+
try {
|
|
183
|
+
execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, {
|
|
184
|
+
encoding: 'utf8',
|
|
185
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
186
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* Non-critical — key may not be needed */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
|
|
197
|
+
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
|
|
198
|
+
*/
|
|
199
|
+
function setOpenCodeConfigContent(muxName, config) {
|
|
200
|
+
if (!config)
|
|
201
|
+
return;
|
|
202
|
+
let jsonContent;
|
|
203
|
+
if (config.autoAllowTools) {
|
|
204
|
+
const permConfig = { permission: { '*': 'allow' } };
|
|
205
|
+
if (config.configContent) {
|
|
206
|
+
try {
|
|
207
|
+
const existing = JSON.parse(config.configContent);
|
|
208
|
+
Object.assign(permConfig, existing);
|
|
209
|
+
permConfig.permission = { '*': 'allow' };
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* invalid JSON, use default permConfig */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
jsonContent = JSON.stringify(permConfig);
|
|
216
|
+
}
|
|
217
|
+
else if (config.configContent) {
|
|
218
|
+
// Validate JSON to prevent garbage config
|
|
219
|
+
try {
|
|
220
|
+
JSON.parse(config.configContent);
|
|
221
|
+
jsonContent = config.configContent;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
console.error('[TmuxManager] Invalid JSON in openCodeConfig.configContent, skipping');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (jsonContent) {
|
|
229
|
+
const escaped = jsonContent.replace(/'/g, "'\\''");
|
|
230
|
+
try {
|
|
231
|
+
execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
|
|
232
|
+
encoding: 'utf8',
|
|
233
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
234
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
/* Non-critical */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Manages tmux sessions that wrap Claude CLI or shell processes.
|
|
244
|
+
*
|
|
245
|
+
* Implements the TerminalMultiplexer interface.
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```typescript
|
|
249
|
+
* const manager = new TmuxManager();
|
|
250
|
+
*
|
|
251
|
+
* // Create a tmux session for Claude
|
|
252
|
+
* const session = await manager.createSession({ sessionId, workingDir: '/project', mode: 'claude' });
|
|
253
|
+
*
|
|
254
|
+
* // Send input (single command, no delay!)
|
|
255
|
+
* manager.sendInput(sessionId, '/clear\r');
|
|
256
|
+
*
|
|
257
|
+
* // Kill when done
|
|
258
|
+
* await manager.killSession(sessionId);
|
|
259
|
+
* ```
|
|
260
|
+
*/
|
|
261
|
+
export class TmuxManager extends EventEmitter {
|
|
262
|
+
backend = 'tmux';
|
|
263
|
+
sessions = new Map();
|
|
264
|
+
statsInterval = null;
|
|
265
|
+
mouseSyncInterval = null;
|
|
266
|
+
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
|
|
267
|
+
lastPaneCount = new Map();
|
|
268
|
+
trueColorConfigured = false;
|
|
269
|
+
constructor() {
|
|
270
|
+
super();
|
|
271
|
+
this.setMaxListeners(50);
|
|
272
|
+
if (!IS_TEST_MODE) {
|
|
273
|
+
this.loadSessions();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Load saved sessions from disk (NEVER called in test mode)
|
|
277
|
+
loadSessions() {
|
|
278
|
+
if (IS_TEST_MODE)
|
|
279
|
+
return;
|
|
280
|
+
try {
|
|
281
|
+
if (existsSync(MUX_SESSIONS_FILE)) {
|
|
282
|
+
const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
|
|
283
|
+
const data = JSON.parse(content);
|
|
284
|
+
if (Array.isArray(data)) {
|
|
285
|
+
for (const session of data) {
|
|
286
|
+
this.sessions.set(session.sessionId, session);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
console.error('[TmuxManager] Failed to load sessions:', err);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Save sessions to disk asynchronously. (NEVER writes in test mode)
|
|
297
|
+
* Uses atomic temp+rename to prevent corruption on crash.
|
|
298
|
+
*/
|
|
299
|
+
saveSessions() {
|
|
300
|
+
if (IS_TEST_MODE)
|
|
301
|
+
return;
|
|
302
|
+
try {
|
|
303
|
+
const dir = dirname(MUX_SESSIONS_FILE);
|
|
304
|
+
if (!existsSync(dir)) {
|
|
305
|
+
mkdirSync(dir, { recursive: true });
|
|
306
|
+
}
|
|
307
|
+
const data = Array.from(this.sessions.values());
|
|
308
|
+
const json = JSON.stringify(data, null, 2);
|
|
309
|
+
const tempPath = MUX_SESSIONS_FILE + '.tmp';
|
|
310
|
+
writeFile(tempPath, json, 'utf-8')
|
|
311
|
+
.then(() => rename(tempPath, MUX_SESSIONS_FILE))
|
|
312
|
+
.catch((err) => {
|
|
313
|
+
console.error('[TmuxManager] Failed to save sessions:', err);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
console.error('[TmuxManager] Failed to save sessions:', err);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Creates a new tmux session wrapping Claude CLI or a shell.
|
|
322
|
+
* In test mode: creates an in-memory session only (no real tmux session).
|
|
323
|
+
*/
|
|
324
|
+
async createSession(options) {
|
|
325
|
+
const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
|
|
326
|
+
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
|
327
|
+
if (!isValidMuxName(muxName)) {
|
|
328
|
+
throw new Error('Invalid session name: contains unsafe characters');
|
|
329
|
+
}
|
|
330
|
+
if (!isValidPath(workingDir)) {
|
|
331
|
+
throw new Error('Invalid working directory path: contains unsafe characters');
|
|
332
|
+
}
|
|
333
|
+
// TEST MODE: Create in-memory session only — no real tmux session
|
|
334
|
+
if (IS_TEST_MODE) {
|
|
335
|
+
const session = {
|
|
336
|
+
sessionId,
|
|
337
|
+
muxName,
|
|
338
|
+
pid: 99999,
|
|
339
|
+
createdAt: Date.now(),
|
|
340
|
+
workingDir,
|
|
341
|
+
mode,
|
|
342
|
+
attached: false,
|
|
343
|
+
name,
|
|
344
|
+
};
|
|
345
|
+
this.sessions.set(sessionId, session);
|
|
346
|
+
this.emit('sessionCreated', session);
|
|
347
|
+
return session;
|
|
348
|
+
}
|
|
349
|
+
// Resolve CLI binary directory based on mode
|
|
350
|
+
let pathExport = '';
|
|
351
|
+
if (mode === 'claude') {
|
|
352
|
+
const claudeDir = findClaudeDir();
|
|
353
|
+
if (!claudeDir) {
|
|
354
|
+
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
|
355
|
+
}
|
|
356
|
+
pathExport = `export PATH="${claudeDir}:$PATH" && `;
|
|
357
|
+
}
|
|
358
|
+
else if (mode === 'opencode') {
|
|
359
|
+
const openCodeDir = resolveOpenCodeDir();
|
|
360
|
+
if (!openCodeDir) {
|
|
361
|
+
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
|
362
|
+
}
|
|
363
|
+
pathExport = `export PATH="${openCodeDir}:$PATH" && `;
|
|
364
|
+
}
|
|
365
|
+
const envExports = [
|
|
366
|
+
'export LANG=en_US.UTF-8',
|
|
367
|
+
'export LC_ALL=en_US.UTF-8',
|
|
368
|
+
'unset COLORTERM',
|
|
369
|
+
'export CODEMAN_MUX=1',
|
|
370
|
+
`export CODEMAN_SESSION_ID=${sessionId}`,
|
|
371
|
+
`export CODEMAN_MUX_NAME=${muxName}`,
|
|
372
|
+
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
|
373
|
+
];
|
|
374
|
+
// Only unset CLAUDECODE for Claude sessions
|
|
375
|
+
if (mode === 'claude')
|
|
376
|
+
envExports.splice(2, 0, 'unset CLAUDECODE');
|
|
377
|
+
const envExportsStr = envExports.join(' && ');
|
|
378
|
+
const baseCmd = buildSpawnCommand({
|
|
379
|
+
mode,
|
|
380
|
+
sessionId,
|
|
381
|
+
model,
|
|
382
|
+
claudeMode,
|
|
383
|
+
allowedTools,
|
|
384
|
+
openCodeConfig,
|
|
385
|
+
});
|
|
386
|
+
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
|
387
|
+
const cmd = wrapWithNice(baseCmd, config);
|
|
388
|
+
try {
|
|
389
|
+
// Build the full command to run inside tmux
|
|
390
|
+
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
|
|
391
|
+
// Create tmux session in three steps to handle cold-start (no server running)
|
|
392
|
+
// and avoid the race where the command exits before remain-on-exit is set:
|
|
393
|
+
// 1. Create session with default shell (starts tmux server, stays alive)
|
|
394
|
+
// 2. Set remain-on-exit (server now exists, session won't vanish on exit)
|
|
395
|
+
// 3. Replace shell with actual command via respawn-pane (no terminal echo)
|
|
396
|
+
// Unset $TMUX so nested sessions work when the dev server itself runs inside tmux.
|
|
397
|
+
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
|
398
|
+
const cleanEnv = { ...process.env };
|
|
399
|
+
delete cleanEnv.TMUX;
|
|
400
|
+
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`, {
|
|
401
|
+
cwd: workingDir,
|
|
402
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
403
|
+
stdio: 'ignore',
|
|
404
|
+
env: cleanEnv,
|
|
405
|
+
});
|
|
406
|
+
// Set remain-on-exit now that the server is running — must be before respawn-pane
|
|
407
|
+
try {
|
|
408
|
+
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, {
|
|
409
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
410
|
+
stdio: 'ignore',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
/* Non-critical */
|
|
415
|
+
}
|
|
416
|
+
// For OpenCode: set sensitive env vars and config via tmux setenv
|
|
417
|
+
// (not visible in ps output or tmux history, inherited by panes)
|
|
418
|
+
if (mode === 'opencode') {
|
|
419
|
+
setOpenCodeEnvVars(muxName);
|
|
420
|
+
setOpenCodeConfigContent(muxName, openCodeConfig);
|
|
421
|
+
}
|
|
422
|
+
// Replace the shell with the actual command (no echo in terminal)
|
|
423
|
+
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
|
424
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
425
|
+
stdio: 'ignore',
|
|
426
|
+
});
|
|
427
|
+
// Wait for tmux session to be queryable
|
|
428
|
+
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
|
429
|
+
// Non-critical tmux config — run in parallel to avoid blocking event loop.
|
|
430
|
+
// These configure UX niceties (no status bar, true color).
|
|
431
|
+
// Mouse mode is OFF by default so xterm.js handles text selection natively.
|
|
432
|
+
// It gets enabled dynamically when panes are split (agent teams).
|
|
433
|
+
const configPromises = [
|
|
434
|
+
// Disable tmux status bar — Codeman's web UI provides session info
|
|
435
|
+
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
|
|
436
|
+
.then(() => { })
|
|
437
|
+
.catch(() => {
|
|
438
|
+
/* Non-critical — session still works with status bar */
|
|
439
|
+
}),
|
|
440
|
+
// Override global remain-on-exit with session-level setting
|
|
441
|
+
execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
|
|
442
|
+
.then(() => { })
|
|
443
|
+
.catch(() => {
|
|
444
|
+
/* Already set globally as fallback */
|
|
445
|
+
}),
|
|
446
|
+
];
|
|
447
|
+
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
|
448
|
+
if (!this.trueColorConfigured) {
|
|
449
|
+
configPromises.push(execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
|
|
450
|
+
.then(() => {
|
|
451
|
+
this.trueColorConfigured = true;
|
|
452
|
+
})
|
|
453
|
+
.catch(() => {
|
|
454
|
+
/* Non-critical — colors limited to 256 */
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
// Fire-and-forget — these are non-critical UX niceties that don't need
|
|
458
|
+
// to complete before the session is usable. Errors are already swallowed.
|
|
459
|
+
void Promise.all(configPromises);
|
|
460
|
+
// Get the PID of the pane process (retry for tmux server cold-start)
|
|
461
|
+
let pid = this.getPanePid(muxName);
|
|
462
|
+
for (let i = 0; !pid && i < GET_PID_MAX_RETRIES; i++) {
|
|
463
|
+
await new Promise((resolve) => setTimeout(resolve, GET_PID_RETRY_MS));
|
|
464
|
+
pid = this.getPanePid(muxName);
|
|
465
|
+
}
|
|
466
|
+
if (!pid) {
|
|
467
|
+
throw new Error('Failed to get tmux pane PID');
|
|
468
|
+
}
|
|
469
|
+
const session = {
|
|
470
|
+
sessionId,
|
|
471
|
+
muxName,
|
|
472
|
+
pid,
|
|
473
|
+
createdAt: Date.now(),
|
|
474
|
+
workingDir,
|
|
475
|
+
mode,
|
|
476
|
+
attached: false,
|
|
477
|
+
name,
|
|
478
|
+
};
|
|
479
|
+
this.sessions.set(sessionId, session);
|
|
480
|
+
this.saveSessions();
|
|
481
|
+
this.emit('sessionCreated', session);
|
|
482
|
+
return session;
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
throw new Error(`Failed to create tmux session: ${getErrorMessage(err)}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Get the PID of the process running in the tmux pane.
|
|
490
|
+
*/
|
|
491
|
+
getPanePid(muxName) {
|
|
492
|
+
if (IS_TEST_MODE)
|
|
493
|
+
return 99999;
|
|
494
|
+
if (!isValidMuxName(muxName)) {
|
|
495
|
+
console.error('[TmuxManager] Invalid session name in getPanePid:', muxName);
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, {
|
|
500
|
+
encoding: 'utf-8',
|
|
501
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
502
|
+
}).trim();
|
|
503
|
+
const pid = parseInt(output, 10);
|
|
504
|
+
return Number.isNaN(pid) ? null : pid;
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Check if a tmux session exists.
|
|
512
|
+
*/
|
|
513
|
+
muxSessionExists(muxName) {
|
|
514
|
+
return this.sessionExists(muxName);
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Check if the pane in a tmux session is dead (command exited but remain-on-exit keeps it).
|
|
518
|
+
* Returns true if the session exists but the pane's command has exited.
|
|
519
|
+
*/
|
|
520
|
+
isPaneDead(muxName) {
|
|
521
|
+
if (IS_TEST_MODE)
|
|
522
|
+
return false;
|
|
523
|
+
if (!isValidMuxName(muxName))
|
|
524
|
+
return false;
|
|
525
|
+
try {
|
|
526
|
+
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, {
|
|
527
|
+
encoding: 'utf-8',
|
|
528
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
529
|
+
}).trim();
|
|
530
|
+
return output === '1';
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Respawn a dead pane in an existing tmux session.
|
|
538
|
+
* Uses `tmux respawn-pane -k` to restart the command in the same pane,
|
|
539
|
+
* preserving the session and its scrollback buffer.
|
|
540
|
+
*/
|
|
541
|
+
async respawnPane(options) {
|
|
542
|
+
const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options;
|
|
543
|
+
const session = this.sessions.get(sessionId);
|
|
544
|
+
if (!session)
|
|
545
|
+
return null;
|
|
546
|
+
const muxName = session.muxName;
|
|
547
|
+
if (!isValidMuxName(muxName) || !isValidPath(workingDir))
|
|
548
|
+
return null;
|
|
549
|
+
// Resolve CLI binary directory based on mode
|
|
550
|
+
let pathExport = '';
|
|
551
|
+
if (mode === 'claude') {
|
|
552
|
+
const claudeDir = findClaudeDir();
|
|
553
|
+
pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
|
|
554
|
+
}
|
|
555
|
+
else if (mode === 'opencode') {
|
|
556
|
+
const openCodeDir = resolveOpenCodeDir();
|
|
557
|
+
pathExport = openCodeDir ? `export PATH="${openCodeDir}:$PATH" && ` : '';
|
|
558
|
+
}
|
|
559
|
+
const envExports = [
|
|
560
|
+
'export LANG=en_US.UTF-8',
|
|
561
|
+
'export LC_ALL=en_US.UTF-8',
|
|
562
|
+
'unset COLORTERM',
|
|
563
|
+
'export CODEMAN_MUX=1',
|
|
564
|
+
`export CODEMAN_SESSION_ID=${sessionId}`,
|
|
565
|
+
`export CODEMAN_MUX_NAME=${muxName}`,
|
|
566
|
+
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
|
567
|
+
];
|
|
568
|
+
if (mode === 'claude')
|
|
569
|
+
envExports.splice(2, 0, 'unset CLAUDECODE');
|
|
570
|
+
const envExportsStr = envExports.join(' && ');
|
|
571
|
+
const baseCmd = buildSpawnCommand({
|
|
572
|
+
mode,
|
|
573
|
+
sessionId,
|
|
574
|
+
model,
|
|
575
|
+
claudeMode,
|
|
576
|
+
allowedTools,
|
|
577
|
+
openCodeConfig,
|
|
578
|
+
});
|
|
579
|
+
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
|
580
|
+
const cmd = wrapWithNice(baseCmd, config);
|
|
581
|
+
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`;
|
|
582
|
+
try {
|
|
583
|
+
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
|
584
|
+
if (mode === 'opencode') {
|
|
585
|
+
setOpenCodeEnvVars(muxName);
|
|
586
|
+
setOpenCodeConfigContent(muxName, openCodeConfig);
|
|
587
|
+
}
|
|
588
|
+
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
|
|
589
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
590
|
+
});
|
|
591
|
+
// Wait for the respawned process to start
|
|
592
|
+
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
|
593
|
+
const pid = this.getPanePid(muxName);
|
|
594
|
+
if (pid)
|
|
595
|
+
session.pid = pid;
|
|
596
|
+
return pid;
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
console.error('[TmuxManager] Failed to respawn pane:', err);
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
sessionExists(muxName) {
|
|
604
|
+
if (IS_TEST_MODE)
|
|
605
|
+
return false;
|
|
606
|
+
try {
|
|
607
|
+
execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, {
|
|
608
|
+
encoding: 'utf-8',
|
|
609
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
610
|
+
});
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// Get all child process PIDs recursively
|
|
618
|
+
getChildPids(pid) {
|
|
619
|
+
const pids = [];
|
|
620
|
+
try {
|
|
621
|
+
const output = execSync(`pgrep -P ${pid}`, {
|
|
622
|
+
encoding: 'utf-8',
|
|
623
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
624
|
+
}).trim();
|
|
625
|
+
if (output) {
|
|
626
|
+
for (const childPid of output
|
|
627
|
+
.split('\n')
|
|
628
|
+
.map((p) => parseInt(p, 10))
|
|
629
|
+
.filter((p) => !Number.isNaN(p))) {
|
|
630
|
+
pids.push(childPid);
|
|
631
|
+
pids.push(...this.getChildPids(childPid));
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
// No children or command failed
|
|
637
|
+
}
|
|
638
|
+
return pids;
|
|
639
|
+
}
|
|
640
|
+
// Check if a process is still alive
|
|
641
|
+
isProcessAlive(pid) {
|
|
642
|
+
try {
|
|
643
|
+
process.kill(pid, 0);
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// Verify all PIDs are dead, with retry
|
|
651
|
+
async verifyProcessesDead(pids, maxWaitMs = 1000) {
|
|
652
|
+
const startTime = Date.now();
|
|
653
|
+
const checkInterval = 100;
|
|
654
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
655
|
+
const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
|
|
656
|
+
if (aliveCount === 0) {
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
|
660
|
+
}
|
|
661
|
+
const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
|
|
662
|
+
if (stillAlive.length > 0) {
|
|
663
|
+
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
|
664
|
+
}
|
|
665
|
+
return stillAlive.length === 0;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Kill a tmux session and all its child processes.
|
|
669
|
+
* Uses a 4-strategy approach (children → process group → tmux kill → SIGKILL).
|
|
670
|
+
* In test mode: removes from memory only (no real kill).
|
|
671
|
+
*/
|
|
672
|
+
async killSession(sessionId) {
|
|
673
|
+
const session = this.sessions.get(sessionId);
|
|
674
|
+
if (!session) {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
// TEST MODE: Remove from memory only — NEVER touch real tmux sessions
|
|
678
|
+
if (IS_TEST_MODE) {
|
|
679
|
+
this.sessions.delete(sessionId);
|
|
680
|
+
this.emit('sessionKilled', { sessionId });
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
// SAFETY: Never kill the tmux session we're running inside of
|
|
684
|
+
const currentMuxName = process.env.CODEMAN_MUX_NAME;
|
|
685
|
+
if (currentMuxName && session.muxName === currentMuxName) {
|
|
686
|
+
console.error(`[TmuxManager] BLOCKED: Refusing to kill own tmux session: ${session.muxName}`);
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
// Get current PID (may have changed)
|
|
690
|
+
const currentPid = this.getPanePid(session.muxName) || session.pid;
|
|
691
|
+
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
|
|
692
|
+
const allPids = [currentPid];
|
|
693
|
+
// Strategy 1: Kill all child processes recursively
|
|
694
|
+
let childPids = this.getChildPids(currentPid);
|
|
695
|
+
if (childPids.length > 0) {
|
|
696
|
+
console.log(`[TmuxManager] Found ${childPids.length} child processes to kill`);
|
|
697
|
+
allPids.push(...childPids);
|
|
698
|
+
for (const childPid of [...childPids].reverse()) {
|
|
699
|
+
if (this.isProcessAlive(childPid)) {
|
|
700
|
+
try {
|
|
701
|
+
process.kill(childPid, 'SIGTERM');
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
// Process may already be dead
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
|
709
|
+
childPids = this.getChildPids(currentPid);
|
|
710
|
+
for (const childPid of childPids) {
|
|
711
|
+
if (this.isProcessAlive(childPid)) {
|
|
712
|
+
try {
|
|
713
|
+
process.kill(childPid, 'SIGKILL');
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
// Process already terminated
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
// Strategy 2: Kill the entire process group
|
|
722
|
+
if (this.isProcessAlive(currentPid)) {
|
|
723
|
+
try {
|
|
724
|
+
process.kill(-currentPid, 'SIGTERM');
|
|
725
|
+
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
|
|
726
|
+
if (this.isProcessAlive(currentPid)) {
|
|
727
|
+
process.kill(-currentPid, 'SIGKILL');
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
// Process group may not exist or already terminated
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Strategy 3: Kill tmux session by name
|
|
735
|
+
try {
|
|
736
|
+
execSync(`tmux kill-session -t "${session.muxName}" 2>/dev/null`, {
|
|
737
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
// Session may already be dead
|
|
742
|
+
}
|
|
743
|
+
// Strategy 4: Direct kill by PID as final fallback
|
|
744
|
+
if (this.isProcessAlive(currentPid)) {
|
|
745
|
+
try {
|
|
746
|
+
process.kill(currentPid, 'SIGKILL');
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
// Already dead
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
// Verify all processes are dead
|
|
753
|
+
const allDead = await this.verifyProcessesDead(allPids, 2000);
|
|
754
|
+
if (!allDead) {
|
|
755
|
+
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
|
|
756
|
+
}
|
|
757
|
+
this.lastPaneCount.delete(session.muxName);
|
|
758
|
+
this.sessions.delete(sessionId);
|
|
759
|
+
this.saveSessions();
|
|
760
|
+
this.emit('sessionKilled', { sessionId });
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
getSessions() {
|
|
764
|
+
return Array.from(this.sessions.values());
|
|
765
|
+
}
|
|
766
|
+
getSession(sessionId) {
|
|
767
|
+
return this.sessions.get(sessionId);
|
|
768
|
+
}
|
|
769
|
+
updateSessionName(sessionId, name) {
|
|
770
|
+
const session = this.sessions.get(sessionId);
|
|
771
|
+
if (!session) {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
session.name = name;
|
|
775
|
+
this.saveSessions();
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Reconcile tracked sessions with actual running tmux sessions.
|
|
780
|
+
*/
|
|
781
|
+
async reconcileSessions() {
|
|
782
|
+
// TEST MODE: Return all registered sessions as alive, never discover real ones
|
|
783
|
+
if (IS_TEST_MODE) {
|
|
784
|
+
return {
|
|
785
|
+
alive: Array.from(this.sessions.keys()),
|
|
786
|
+
dead: [],
|
|
787
|
+
discovered: [],
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
const alive = [];
|
|
791
|
+
const dead = [];
|
|
792
|
+
const discovered = [];
|
|
793
|
+
// Check known sessions
|
|
794
|
+
for (const [sessionId, session] of this.sessions) {
|
|
795
|
+
if (this.sessionExists(session.muxName)) {
|
|
796
|
+
alive.push(sessionId);
|
|
797
|
+
// Update PID if it changed
|
|
798
|
+
const pid = this.getPanePid(session.muxName);
|
|
799
|
+
if (pid && pid !== session.pid) {
|
|
800
|
+
session.pid = pid;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
dead.push(sessionId);
|
|
805
|
+
this.sessions.delete(sessionId);
|
|
806
|
+
this.emit('sessionDied', { sessionId });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
// Discover unknown codeman sessions
|
|
810
|
+
try {
|
|
811
|
+
const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null || true", {
|
|
812
|
+
encoding: 'utf-8',
|
|
813
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
814
|
+
}).trim();
|
|
815
|
+
for (const line of output.split('\n')) {
|
|
816
|
+
const sessionName = line.trim();
|
|
817
|
+
if (!sessionName || (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')))
|
|
818
|
+
continue;
|
|
819
|
+
// Check if this session is already known
|
|
820
|
+
let isKnown = false;
|
|
821
|
+
for (const session of this.sessions.values()) {
|
|
822
|
+
if (session.muxName === sessionName) {
|
|
823
|
+
isKnown = true;
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (!isKnown) {
|
|
828
|
+
// Extract session ID fragment from name
|
|
829
|
+
const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, '');
|
|
830
|
+
const sessionId = `restored-${fragment}`;
|
|
831
|
+
const pid = this.getPanePid(sessionName);
|
|
832
|
+
if (pid) {
|
|
833
|
+
const session = {
|
|
834
|
+
sessionId,
|
|
835
|
+
muxName: sessionName,
|
|
836
|
+
pid,
|
|
837
|
+
createdAt: Date.now(),
|
|
838
|
+
workingDir: process.cwd(),
|
|
839
|
+
mode: 'claude',
|
|
840
|
+
attached: false,
|
|
841
|
+
name: `Restored: ${sessionName}`,
|
|
842
|
+
};
|
|
843
|
+
this.sessions.set(sessionId, session);
|
|
844
|
+
discovered.push(sessionId);
|
|
845
|
+
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
catch (err) {
|
|
851
|
+
console.error('[TmuxManager] Failed to discover sessions:', err);
|
|
852
|
+
}
|
|
853
|
+
if (dead.length > 0 || discovered.length > 0) {
|
|
854
|
+
this.saveSessions();
|
|
855
|
+
}
|
|
856
|
+
return { alive, dead, discovered };
|
|
857
|
+
}
|
|
858
|
+
async getProcessStats(sessionId) {
|
|
859
|
+
if (IS_TEST_MODE)
|
|
860
|
+
return { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() };
|
|
861
|
+
const session = this.sessions.get(sessionId);
|
|
862
|
+
if (!session) {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
try {
|
|
866
|
+
const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
|
|
867
|
+
encoding: 'utf-8',
|
|
868
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
869
|
+
}).trim();
|
|
870
|
+
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
|
|
871
|
+
let childCount = 0;
|
|
872
|
+
try {
|
|
873
|
+
const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, {
|
|
874
|
+
encoding: 'utf-8',
|
|
875
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
876
|
+
}).trim();
|
|
877
|
+
childCount = parseInt(childOutput, 10) || 0;
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
// No children or command failed
|
|
881
|
+
}
|
|
882
|
+
return {
|
|
883
|
+
memoryMB: Math.round((rss / 1024) * 10) / 10,
|
|
884
|
+
cpuPercent: Math.round(cpu * 10) / 10,
|
|
885
|
+
childCount,
|
|
886
|
+
updatedAt: Date.now(),
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
async getSessionsWithStats() {
|
|
894
|
+
if (IS_TEST_MODE) {
|
|
895
|
+
return Array.from(this.sessions.values()).map((s) => ({
|
|
896
|
+
...s,
|
|
897
|
+
stats: { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() },
|
|
898
|
+
}));
|
|
899
|
+
}
|
|
900
|
+
const sessions = Array.from(this.sessions.values());
|
|
901
|
+
if (sessions.length === 0) {
|
|
902
|
+
return [];
|
|
903
|
+
}
|
|
904
|
+
const sessionPids = sessions.map((s) => s.pid);
|
|
905
|
+
const statsMap = new Map();
|
|
906
|
+
try {
|
|
907
|
+
// Step 1: Get descendant PIDs
|
|
908
|
+
const descendantMap = new Map();
|
|
909
|
+
const pgrepOutput = execSync(`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`, {
|
|
910
|
+
encoding: 'utf-8',
|
|
911
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
912
|
+
}).trim();
|
|
913
|
+
for (const line of pgrepOutput.split('\n')) {
|
|
914
|
+
const [pidStr, childrenStr] = line.split(':');
|
|
915
|
+
const sessionPid = parseInt(pidStr, 10);
|
|
916
|
+
if (!Number.isNaN(sessionPid)) {
|
|
917
|
+
const children = (childrenStr || '')
|
|
918
|
+
.split(',')
|
|
919
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
920
|
+
.filter((n) => !Number.isNaN(n) && n > 0);
|
|
921
|
+
descendantMap.set(sessionPid, children);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
// Step 2: Collect all PIDs
|
|
925
|
+
const allPids = new Set(sessionPids);
|
|
926
|
+
for (const children of descendantMap.values()) {
|
|
927
|
+
for (const child of children) {
|
|
928
|
+
allPids.add(child);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// Step 3: Single ps call
|
|
932
|
+
const pidArray = Array.from(allPids);
|
|
933
|
+
if (pidArray.length > 0) {
|
|
934
|
+
const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
|
|
935
|
+
encoding: 'utf-8',
|
|
936
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
937
|
+
}).trim();
|
|
938
|
+
const processStats = new Map();
|
|
939
|
+
for (const line of psOutput.split('\n')) {
|
|
940
|
+
const parts = line.trim().split(/\s+/);
|
|
941
|
+
if (parts.length >= 3) {
|
|
942
|
+
const pid = parseInt(parts[0], 10);
|
|
943
|
+
const rss = parseFloat(parts[1]) || 0;
|
|
944
|
+
const cpu = parseFloat(parts[2]) || 0;
|
|
945
|
+
if (!Number.isNaN(pid)) {
|
|
946
|
+
processStats.set(pid, { rss, cpu });
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
// Step 4: Aggregate stats
|
|
951
|
+
for (const sessionPid of sessionPids) {
|
|
952
|
+
const children = descendantMap.get(sessionPid) || [];
|
|
953
|
+
const sessionStats = processStats.get(sessionPid) || { rss: 0, cpu: 0 };
|
|
954
|
+
let totalRss = sessionStats.rss;
|
|
955
|
+
let totalCpu = sessionStats.cpu;
|
|
956
|
+
for (const childPid of children) {
|
|
957
|
+
const childStats = processStats.get(childPid);
|
|
958
|
+
if (childStats) {
|
|
959
|
+
totalRss += childStats.rss;
|
|
960
|
+
totalCpu += childStats.cpu;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
statsMap.set(sessionPid, {
|
|
964
|
+
memoryMB: Math.round((totalRss / 1024) * 10) / 10,
|
|
965
|
+
cpuPercent: Math.round(totalCpu * 10) / 10,
|
|
966
|
+
childCount: children.length,
|
|
967
|
+
updatedAt: Date.now(),
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
// Fall back to individual queries
|
|
974
|
+
const statsPromises = sessions.map((session) => this.getProcessStats(session.sessionId));
|
|
975
|
+
const results = await Promise.allSettled(statsPromises);
|
|
976
|
+
return sessions.map((session, i) => ({
|
|
977
|
+
...session,
|
|
978
|
+
stats: results[i].status === 'fulfilled' ? (results[i].value ?? undefined) : undefined,
|
|
979
|
+
}));
|
|
980
|
+
}
|
|
981
|
+
return sessions.map((session) => ({
|
|
982
|
+
...session,
|
|
983
|
+
stats: statsMap.get(session.pid) || undefined,
|
|
984
|
+
}));
|
|
985
|
+
}
|
|
986
|
+
startStatsCollection(intervalMs = DEFAULT_STATS_INTERVAL_MS) {
|
|
987
|
+
if (this.statsInterval) {
|
|
988
|
+
clearInterval(this.statsInterval);
|
|
989
|
+
}
|
|
990
|
+
this.statsInterval = setInterval(async () => {
|
|
991
|
+
try {
|
|
992
|
+
const sessionsWithStats = await this.getSessionsWithStats();
|
|
993
|
+
this.emit('statsUpdated', sessionsWithStats);
|
|
994
|
+
}
|
|
995
|
+
catch (err) {
|
|
996
|
+
console.error('[TmuxManager] Stats collection error:', err);
|
|
997
|
+
}
|
|
998
|
+
}, intervalMs);
|
|
999
|
+
}
|
|
1000
|
+
stopStatsCollection() {
|
|
1001
|
+
if (this.statsInterval) {
|
|
1002
|
+
clearInterval(this.statsInterval);
|
|
1003
|
+
this.statsInterval = null;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Start periodic mouse mode sync for all tracked sessions.
|
|
1008
|
+
* Polls pane counts every 5s and toggles mouse on/off as needed.
|
|
1009
|
+
* Polls every 5s. On pane count change, toggles mouse on (>1 pane) or off (1 pane).
|
|
1010
|
+
* If enableMouseMode/disableMouseMode fails, lastPaneCount is NOT updated so it retries next poll.
|
|
1011
|
+
*/
|
|
1012
|
+
startMouseModeSync(intervalMs = 5000) {
|
|
1013
|
+
if (this.mouseSyncInterval) {
|
|
1014
|
+
clearInterval(this.mouseSyncInterval);
|
|
1015
|
+
}
|
|
1016
|
+
this.mouseSyncInterval = setInterval(() => {
|
|
1017
|
+
if (IS_TEST_MODE)
|
|
1018
|
+
return;
|
|
1019
|
+
for (const session of this.sessions.values()) {
|
|
1020
|
+
const panes = this.listPanes(session.muxName);
|
|
1021
|
+
const count = panes.length;
|
|
1022
|
+
if (count === 0)
|
|
1023
|
+
continue;
|
|
1024
|
+
const prev = this.lastPaneCount.get(session.muxName);
|
|
1025
|
+
if (prev === count)
|
|
1026
|
+
continue;
|
|
1027
|
+
// Pane count changed — toggle mouse mode
|
|
1028
|
+
if (count > 1) {
|
|
1029
|
+
if (this.enableMouseMode(session.muxName)) {
|
|
1030
|
+
this.lastPaneCount.set(session.muxName, count);
|
|
1031
|
+
}
|
|
1032
|
+
// If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
if (this.disableMouseMode(session.muxName)) {
|
|
1036
|
+
this.lastPaneCount.set(session.muxName, count);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}, intervalMs);
|
|
1041
|
+
}
|
|
1042
|
+
stopMouseModeSync() {
|
|
1043
|
+
if (this.mouseSyncInterval) {
|
|
1044
|
+
clearInterval(this.mouseSyncInterval);
|
|
1045
|
+
this.mouseSyncInterval = null;
|
|
1046
|
+
}
|
|
1047
|
+
this.lastPaneCount.clear();
|
|
1048
|
+
}
|
|
1049
|
+
destroy() {
|
|
1050
|
+
this.stopStatsCollection();
|
|
1051
|
+
this.stopMouseModeSync();
|
|
1052
|
+
}
|
|
1053
|
+
registerSession(session) {
|
|
1054
|
+
this.sessions.set(session.sessionId, session);
|
|
1055
|
+
this.saveSessions();
|
|
1056
|
+
}
|
|
1057
|
+
setAttached(sessionId, attached) {
|
|
1058
|
+
const session = this.sessions.get(sessionId);
|
|
1059
|
+
if (session) {
|
|
1060
|
+
session.attached = attached;
|
|
1061
|
+
this.saveSessions();
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
updateRespawnConfig(sessionId, config) {
|
|
1065
|
+
const session = this.sessions.get(sessionId);
|
|
1066
|
+
if (session) {
|
|
1067
|
+
session.respawnConfig = config;
|
|
1068
|
+
this.saveSessions();
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
clearRespawnConfig(sessionId) {
|
|
1072
|
+
const session = this.sessions.get(sessionId);
|
|
1073
|
+
if (session && session.respawnConfig) {
|
|
1074
|
+
delete session.respawnConfig;
|
|
1075
|
+
this.saveSessions();
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
updateRalphEnabled(sessionId, enabled) {
|
|
1079
|
+
const session = this.sessions.get(sessionId);
|
|
1080
|
+
if (session) {
|
|
1081
|
+
session.ralphEnabled = enabled;
|
|
1082
|
+
this.saveSessions();
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Send input directly to a tmux session using `send-keys`.
|
|
1087
|
+
*
|
|
1088
|
+
* Uses tmux send-keys for reliable input delivery:
|
|
1089
|
+
* - `-l` flag sends literal text (no key interpretation)
|
|
1090
|
+
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
|
|
1091
|
+
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
|
|
1092
|
+
*/
|
|
1093
|
+
async sendInput(sessionId, input) {
|
|
1094
|
+
const session = this.sessions.get(sessionId);
|
|
1095
|
+
if (!session) {
|
|
1096
|
+
console.error(`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`);
|
|
1097
|
+
return false;
|
|
1098
|
+
}
|
|
1099
|
+
// TEST MODE: No-op — don't send input to real tmux sessions
|
|
1100
|
+
if (IS_TEST_MODE) {
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
console.log(`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`);
|
|
1104
|
+
if (!isValidMuxName(session.muxName)) {
|
|
1105
|
+
console.error('[TmuxManager] Invalid session name in sendInput:', session.muxName);
|
|
1106
|
+
return false;
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
const hasCarriageReturn = input.includes('\r');
|
|
1110
|
+
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
|
1111
|
+
if (textPart && hasCarriageReturn) {
|
|
1112
|
+
// Send text first, then Enter as a SEPARATE tmux command after a short delay.
|
|
1113
|
+
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
|
|
1114
|
+
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
|
|
1115
|
+
// character in the input buffer rather than as form submission.
|
|
1116
|
+
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
|
1117
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1118
|
+
});
|
|
1119
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1120
|
+
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
|
|
1121
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
else if (textPart) {
|
|
1125
|
+
// Text only, no Enter
|
|
1126
|
+
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
|
1127
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
else if (hasCarriageReturn) {
|
|
1131
|
+
// Enter only
|
|
1132
|
+
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, {
|
|
1133
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
catch (err) {
|
|
1139
|
+
console.error('[TmuxManager] Failed to send input:', err);
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
// ========== Pane Methods (for Agent Team teammate panes) ==========
|
|
1144
|
+
/**
|
|
1145
|
+
* Enable mouse mode for an existing tmux session.
|
|
1146
|
+
* Allows clicking to select panes in agent team split-pane layouts.
|
|
1147
|
+
* When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy).
|
|
1148
|
+
*/
|
|
1149
|
+
enableMouseMode(muxName) {
|
|
1150
|
+
if (IS_TEST_MODE)
|
|
1151
|
+
return true;
|
|
1152
|
+
if (!isValidMuxName(muxName)) {
|
|
1153
|
+
console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName);
|
|
1154
|
+
return false;
|
|
1155
|
+
}
|
|
1156
|
+
try {
|
|
1157
|
+
execSync(`tmux set-option -t "${muxName}" mouse on`, {
|
|
1158
|
+
encoding: 'utf-8',
|
|
1159
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1160
|
+
});
|
|
1161
|
+
console.log(`[TmuxManager] Mouse mode ON for ${muxName}`);
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
catch (err) {
|
|
1165
|
+
console.error(`[TmuxManager] Failed to enable mouse mode for ${muxName}:`, err);
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Disable mouse mode for an existing tmux session.
|
|
1171
|
+
* Restores native xterm.js text selection and browser clipboard copy.
|
|
1172
|
+
*/
|
|
1173
|
+
disableMouseMode(muxName) {
|
|
1174
|
+
if (IS_TEST_MODE)
|
|
1175
|
+
return true;
|
|
1176
|
+
if (!isValidMuxName(muxName)) {
|
|
1177
|
+
console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName);
|
|
1178
|
+
return false;
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
execSync(`tmux set-option -t "${muxName}" mouse off`, {
|
|
1182
|
+
encoding: 'utf-8',
|
|
1183
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1184
|
+
});
|
|
1185
|
+
console.log(`[TmuxManager] Mouse mode OFF for ${muxName}`);
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1188
|
+
catch (err) {
|
|
1189
|
+
console.error(`[TmuxManager] Failed to disable mouse mode for ${muxName}:`, err);
|
|
1190
|
+
return false;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Sync mouse mode based on pane count: enable if split (>1 pane), disable if single.
|
|
1195
|
+
* Called by TeamWatcher when teammates spawn/despawn panes.
|
|
1196
|
+
* Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config.
|
|
1197
|
+
*/
|
|
1198
|
+
syncMouseMode(muxName) {
|
|
1199
|
+
if (IS_TEST_MODE)
|
|
1200
|
+
return true;
|
|
1201
|
+
const panes = this.listPanes(muxName);
|
|
1202
|
+
if (panes.length > 1) {
|
|
1203
|
+
return this.enableMouseMode(muxName);
|
|
1204
|
+
}
|
|
1205
|
+
else {
|
|
1206
|
+
return this.disableMouseMode(muxName);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* List all panes in a tmux session.
|
|
1211
|
+
* Returns structured info for each pane.
|
|
1212
|
+
*/
|
|
1213
|
+
listPanes(muxName) {
|
|
1214
|
+
if (IS_TEST_MODE)
|
|
1215
|
+
return [];
|
|
1216
|
+
if (!isValidMuxName(muxName)) {
|
|
1217
|
+
console.error('[TmuxManager] Invalid session name in listPanes:', muxName);
|
|
1218
|
+
return [];
|
|
1219
|
+
}
|
|
1220
|
+
try {
|
|
1221
|
+
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();
|
|
1222
|
+
return output
|
|
1223
|
+
.split('\n')
|
|
1224
|
+
.map((line) => {
|
|
1225
|
+
const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
|
|
1226
|
+
return {
|
|
1227
|
+
paneId,
|
|
1228
|
+
paneIndex: parseInt(indexStr, 10),
|
|
1229
|
+
panePid: parseInt(pidStr, 10),
|
|
1230
|
+
width: parseInt(widthStr, 10),
|
|
1231
|
+
height: parseInt(heightStr, 10),
|
|
1232
|
+
};
|
|
1233
|
+
})
|
|
1234
|
+
.filter((p) => !Number.isNaN(p.paneIndex));
|
|
1235
|
+
}
|
|
1236
|
+
catch {
|
|
1237
|
+
return [];
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Send input to a specific pane within a tmux session.
|
|
1242
|
+
* Uses the same literal text approach as sendInput() but targets a specific pane.
|
|
1243
|
+
*/
|
|
1244
|
+
sendInputToPane(muxName, paneTarget, input) {
|
|
1245
|
+
if (IS_TEST_MODE)
|
|
1246
|
+
return true;
|
|
1247
|
+
if (!isValidMuxName(muxName)) {
|
|
1248
|
+
console.error('[TmuxManager] Invalid session name in sendInputToPane:', muxName);
|
|
1249
|
+
return false;
|
|
1250
|
+
}
|
|
1251
|
+
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
|
1252
|
+
console.error('[TmuxManager] Invalid pane target:', paneTarget);
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
|
|
1256
|
+
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
|
1257
|
+
try {
|
|
1258
|
+
const hasCarriageReturn = input.includes('\r');
|
|
1259
|
+
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
|
1260
|
+
if (textPart && hasCarriageReturn) {
|
|
1261
|
+
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
|
1262
|
+
encoding: 'utf-8',
|
|
1263
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1264
|
+
});
|
|
1265
|
+
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
|
|
1266
|
+
encoding: 'utf-8',
|
|
1267
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
else if (textPart) {
|
|
1271
|
+
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
|
1272
|
+
encoding: 'utf-8',
|
|
1273
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
else if (hasCarriageReturn) {
|
|
1277
|
+
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, {
|
|
1278
|
+
encoding: 'utf-8',
|
|
1279
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
return true;
|
|
1283
|
+
}
|
|
1284
|
+
catch (err) {
|
|
1285
|
+
console.error('[TmuxManager] Failed to send input to pane:', err);
|
|
1286
|
+
return false;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Capture the current buffer of a specific pane.
|
|
1291
|
+
* Returns the pane content with ANSI escape codes preserved.
|
|
1292
|
+
*/
|
|
1293
|
+
capturePaneBuffer(muxName, paneTarget) {
|
|
1294
|
+
if (IS_TEST_MODE)
|
|
1295
|
+
return '';
|
|
1296
|
+
if (!isValidMuxName(muxName)) {
|
|
1297
|
+
console.error('[TmuxManager] Invalid session name in capturePaneBuffer:', muxName);
|
|
1298
|
+
return null;
|
|
1299
|
+
}
|
|
1300
|
+
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
|
1301
|
+
console.error('[TmuxManager] Invalid pane target:', paneTarget);
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
|
1305
|
+
try {
|
|
1306
|
+
return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
|
|
1307
|
+
encoding: 'utf-8',
|
|
1308
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
catch (err) {
|
|
1312
|
+
console.error('[TmuxManager] Failed to capture pane buffer:', err);
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Start piping pane output to a file using tmux pipe-pane.
|
|
1318
|
+
* Only pipes output direction (-O) to avoid echoing input.
|
|
1319
|
+
*/
|
|
1320
|
+
startPipePane(muxName, paneTarget, outputFile) {
|
|
1321
|
+
if (IS_TEST_MODE)
|
|
1322
|
+
return true;
|
|
1323
|
+
if (!isValidMuxName(muxName)) {
|
|
1324
|
+
console.error('[TmuxManager] Invalid session name in startPipePane:', muxName);
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
|
1328
|
+
console.error('[TmuxManager] Invalid pane target:', paneTarget);
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
if (!isValidPath(outputFile)) {
|
|
1332
|
+
console.error('[TmuxManager] Invalid output file path:', outputFile);
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
|
1336
|
+
try {
|
|
1337
|
+
execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
|
|
1338
|
+
encoding: 'utf-8',
|
|
1339
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1340
|
+
});
|
|
1341
|
+
return true;
|
|
1342
|
+
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
console.error('[TmuxManager] Failed to start pipe-pane:', err);
|
|
1345
|
+
return false;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Stop piping pane output (calling pipe-pane with no command stops piping).
|
|
1350
|
+
*/
|
|
1351
|
+
stopPipePane(muxName, paneTarget) {
|
|
1352
|
+
if (IS_TEST_MODE)
|
|
1353
|
+
return true;
|
|
1354
|
+
if (!isValidMuxName(muxName)) {
|
|
1355
|
+
console.error('[TmuxManager] Invalid session name in stopPipePane:', muxName);
|
|
1356
|
+
return false;
|
|
1357
|
+
}
|
|
1358
|
+
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
|
1359
|
+
console.error('[TmuxManager] Invalid pane target:', paneTarget);
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
|
1363
|
+
try {
|
|
1364
|
+
execSync(`tmux pipe-pane -t ${shellescape(target)}`, {
|
|
1365
|
+
encoding: 'utf-8',
|
|
1366
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
1367
|
+
});
|
|
1368
|
+
return true;
|
|
1369
|
+
}
|
|
1370
|
+
catch (err) {
|
|
1371
|
+
console.error('[TmuxManager] Failed to stop pipe-pane:', err);
|
|
1372
|
+
return false;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
getAttachCommand() {
|
|
1376
|
+
return 'tmux';
|
|
1377
|
+
}
|
|
1378
|
+
getAttachArgs(muxName) {
|
|
1379
|
+
return ['attach-session', '-t', muxName];
|
|
1380
|
+
}
|
|
1381
|
+
isAvailable() {
|
|
1382
|
+
return TmuxManager.isTmuxAvailable();
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Check if tmux is available on the system.
|
|
1386
|
+
*/
|
|
1387
|
+
static isTmuxAvailable() {
|
|
1388
|
+
try {
|
|
1389
|
+
execSync('which tmux', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS });
|
|
1390
|
+
return true;
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
return false;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Shell-escape a string for use as a single argument.
|
|
1399
|
+
* Wraps in single quotes, escaping any embedded single quotes.
|
|
1400
|
+
*/
|
|
1401
|
+
function shellescape(str) {
|
|
1402
|
+
// Replace single quotes with '\'' (end quote, escaped quote, restart quote)
|
|
1403
|
+
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
1404
|
+
}
|
|
1405
|
+
//# sourceMappingURL=tmux-manager.js.map
|