aegis-bridge 2.2.2
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 +244 -0
- package/dashboard/dist/assets/index-CijFoeRu.css +32 -0
- package/dashboard/dist/assets/index-QtT4j0ht.js +262 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/auth.d.ts +76 -0
- package/dist/auth.js +219 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +9 -0
- package/dist/channels/manager.d.ts +39 -0
- package/dist/channels/manager.js +101 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +203 -0
- package/dist/channels/telegram.d.ts +76 -0
- package/dist/channels/telegram.js +1396 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +9 -0
- package/dist/channels/webhook.d.ts +58 -0
- package/dist/channels/webhook.js +162 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +223 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.js +188 -0
- package/dist/dashboard/assets/index-CijFoeRu.css +32 -0
- package/dist/dashboard/assets/index-QtT4j0ht.js +262 -0
- package/dist/dashboard/index.html +14 -0
- package/dist/events.d.ts +86 -0
- package/dist/events.js +258 -0
- package/dist/hook-settings.d.ts +67 -0
- package/dist/hook-settings.js +138 -0
- package/dist/hook.d.ts +18 -0
- package/dist/hook.js +199 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +279 -0
- package/dist/jsonl-watcher.d.ts +57 -0
- package/dist/jsonl-watcher.js +159 -0
- package/dist/mcp-server.d.ts +60 -0
- package/dist/mcp-server.js +788 -0
- package/dist/metrics.d.ts +104 -0
- package/dist/metrics.js +226 -0
- package/dist/monitor.d.ts +84 -0
- package/dist/monitor.js +553 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +197 -0
- package/dist/pipeline.d.ts +84 -0
- package/dist/pipeline.js +218 -0
- package/dist/screenshot.d.ts +26 -0
- package/dist/screenshot.js +57 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1577 -0
- package/dist/session.d.ts +297 -0
- package/dist/session.js +1275 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +62 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +95 -0
- package/dist/ssrf.d.ts +57 -0
- package/dist/ssrf.js +169 -0
- package/dist/swarm-monitor.d.ts +114 -0
- package/dist/swarm-monitor.js +267 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +343 -0
- package/dist/tmux.d.ts +161 -0
- package/dist/tmux.js +725 -0
- package/dist/transcript.d.ts +47 -0
- package/dist/transcript.js +244 -0
- package/dist/validation.d.ts +222 -0
- package/dist/validation.js +268 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +297 -0
- package/package.json +71 -0
package/dist/tmux.js
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux.ts — Low-level tmux interaction layer.
|
|
3
|
+
*
|
|
4
|
+
* Wraps tmux CLI commands to manage windows inside a named session.
|
|
5
|
+
* Port of CCBot's tmux_manager.py to TypeScript.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { readdir, rename as fsRename, mkdir, stat } from 'node:fs/promises';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { homedir, tmpdir } from 'node:os';
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
/** Shell-escape a string by wrapping in single quotes and escaping embedded single quotes. */
|
|
15
|
+
function shellEscape(s) {
|
|
16
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
17
|
+
}
|
|
18
|
+
/** Validate that an env var key contains only safe characters. */
|
|
19
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
21
|
+
/** Default timeout for tmux commands (ms). Prevents hung commands from blocking the server. */
|
|
22
|
+
const TMUX_DEFAULT_TIMEOUT_MS = 10_000;
|
|
23
|
+
/** Thrown when a tmux command exceeds its timeout. */
|
|
24
|
+
export class TmuxTimeoutError extends Error {
|
|
25
|
+
constructor(args, timeoutMs) {
|
|
26
|
+
super(`tmux command timed out after ${timeoutMs}ms: tmux ${args.join(' ')}`);
|
|
27
|
+
this.name = 'TmuxTimeoutError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class TmuxManager {
|
|
31
|
+
sessionName;
|
|
32
|
+
/** tmux socket name (-L flag). Isolates sessions from other tmux instances. */
|
|
33
|
+
socketName;
|
|
34
|
+
/** #357: Cache for window existence checks — avoids repeated tmux CLI calls. */
|
|
35
|
+
windowExistsCache = new Map();
|
|
36
|
+
static WINDOW_CACHE_TTL_MS = 2_000;
|
|
37
|
+
constructor(sessionName = 'aegis', socketName) {
|
|
38
|
+
this.sessionName = sessionName;
|
|
39
|
+
this.socketName = socketName ?? `aegis-${process.pid}`;
|
|
40
|
+
}
|
|
41
|
+
/** Promise-chain queue that serializes all tmux CLI calls to prevent race conditions. */
|
|
42
|
+
queue = Promise.resolve(undefined);
|
|
43
|
+
/** #403: Counter of in-flight createWindow calls — direct methods must queue when > 0. */
|
|
44
|
+
_creatingCount = 0;
|
|
45
|
+
/** #357: Short-lived cache for window existence checks to reduce CLI calls. */
|
|
46
|
+
windowCache = new Map();
|
|
47
|
+
/** Run `fn` sequentially after all previously-queued operations complete. */
|
|
48
|
+
serialize(fn) {
|
|
49
|
+
let resolve;
|
|
50
|
+
const next = new Promise(r => { resolve = r; });
|
|
51
|
+
const prev = this.queue;
|
|
52
|
+
this.queue = next;
|
|
53
|
+
return prev.then(async () => {
|
|
54
|
+
try {
|
|
55
|
+
return await fn();
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
resolve();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Run a tmux command and return stdout (serialized through the queue).
|
|
63
|
+
* Issue #66: All tmux commands have a timeout to prevent hangs.
|
|
64
|
+
* A single hung tmux command would otherwise block the entire Aegis server.
|
|
65
|
+
*/
|
|
66
|
+
async tmux(...args) {
|
|
67
|
+
return this.serialize(() => this.tmuxInternal(...args));
|
|
68
|
+
}
|
|
69
|
+
async tmuxInternal(...args) {
|
|
70
|
+
try {
|
|
71
|
+
const { stdout } = await execFileAsync('tmux', ['-L', this.socketName, ...args], {
|
|
72
|
+
timeout: TMUX_DEFAULT_TIMEOUT_MS,
|
|
73
|
+
});
|
|
74
|
+
return stdout.trim();
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
// Node.js sets `killed` on the error when the process was killed due to timeout
|
|
78
|
+
if (e && typeof e === 'object' && 'killed' in e && e.killed) {
|
|
79
|
+
throw new TmuxTimeoutError(args, TMUX_DEFAULT_TIMEOUT_MS);
|
|
80
|
+
}
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Ensure our tmux session exists and is healthy.
|
|
85
|
+
* Issue #7: After prolonged uptime, tmux session may exist but be degraded.
|
|
86
|
+
* We verify by listing windows — if that fails, recreate the session.
|
|
87
|
+
*/
|
|
88
|
+
async ensureSession() {
|
|
89
|
+
return this.ensureSessionInternal();
|
|
90
|
+
}
|
|
91
|
+
/** #403: Internal version that calls tmuxInternal directly (safe inside serialize). */
|
|
92
|
+
async ensureSessionInternal() {
|
|
93
|
+
try {
|
|
94
|
+
await this.tmuxInternal('has-session', '-t', this.sessionName);
|
|
95
|
+
// Session exists — verify it's healthy by listing windows
|
|
96
|
+
await this.tmuxInternal('list-windows', '-t', this.sessionName, '-F', '#{window_id}');
|
|
97
|
+
}
|
|
98
|
+
catch { /* session missing or unhealthy — (re)create below */
|
|
99
|
+
// Session doesn't exist or is unhealthy — (re)create it.
|
|
100
|
+
// KillMode=process in the systemd service ensures only the node server
|
|
101
|
+
// is killed on restart, not tmux or Claude Code processes inside.
|
|
102
|
+
try {
|
|
103
|
+
// Kill the broken session first if it exists
|
|
104
|
+
await this.tmuxInternal('kill-session', '-t', this.sessionName);
|
|
105
|
+
}
|
|
106
|
+
catch { /* session may not exist */ }
|
|
107
|
+
await this.tmuxInternal('new-session', '-d', '-s', this.sessionName, '-n', '_bridge_main', '-x', '220', '-y', '50');
|
|
108
|
+
console.log(`Tmux: session '${this.sessionName}' (re)created`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** List all windows (excluding the placeholder _bridge_main). */
|
|
112
|
+
async listWindows() {
|
|
113
|
+
await this.ensureSession();
|
|
114
|
+
try {
|
|
115
|
+
const raw = await this.tmux('list-windows', '-t', this.sessionName, '-F', '#{window_id}\t#{window_name}\t#{pane_current_path}\t#{pane_current_command}');
|
|
116
|
+
if (!raw)
|
|
117
|
+
return [];
|
|
118
|
+
return raw.split('\n').filter(Boolean).map(line => {
|
|
119
|
+
const [windowId, windowName, cwd, paneCommand] = line.split('\t');
|
|
120
|
+
return { windowId, windowName, cwd, paneCommand };
|
|
121
|
+
}).filter(w => w.windowName !== '_bridge_main');
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
console.warn(`Tmux: listWindows failed: ${e.message}`);
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Create a new window, start claude, return window info.
|
|
129
|
+
* Issue #7: Retries up to 3x on failure, with tmux session health check between retries.
|
|
130
|
+
*/
|
|
131
|
+
async createWindow(opts) {
|
|
132
|
+
// #403: Wrap the entire ensureSession + mkdir + name check + window creation
|
|
133
|
+
// in a single serialize() scope so concurrent createWindow calls cannot
|
|
134
|
+
// interleave between the name availability check and window creation.
|
|
135
|
+
// Previous fix (#363) only wrapped name-check+creation but left ensureSession
|
|
136
|
+
// and mkdir outside — the gap between ensureSession completing and the
|
|
137
|
+
// serialize block entering allowed concurrent calls to interleave.
|
|
138
|
+
this._creatingCount++;
|
|
139
|
+
let windowId = '';
|
|
140
|
+
let finalName = '';
|
|
141
|
+
try {
|
|
142
|
+
const creationResult = await this.serialize(async () => {
|
|
143
|
+
// #403: ensureSession and mkdir inside serialize so the whole
|
|
144
|
+
// sequence is atomic with respect to other createWindow calls.
|
|
145
|
+
// Uses ensureSessionInternal (tmuxInternal) to avoid re-entering serialize.
|
|
146
|
+
await this.ensureSessionInternal();
|
|
147
|
+
// Issue #31: Ensure workDir exists before creating tmux window.
|
|
148
|
+
// If it doesn't exist, tmux uses $HOME and CC starts in wrong directory.
|
|
149
|
+
await mkdir(opts.workDir, { recursive: true });
|
|
150
|
+
// Check for name collision, add suffix if needed
|
|
151
|
+
let name = opts.windowName;
|
|
152
|
+
// #393 fix: use tmuxInternal directly (not listWindows) to avoid
|
|
153
|
+
// re-entering serialize() from inside a serialize() callback → deadlock.
|
|
154
|
+
const rawWindows = await this.tmuxInternal('list-windows', '-t', this.sessionName, '-F', '#{window_id}\t#{window_name}\t#{pane_current_path}\t#{pane_current_command}');
|
|
155
|
+
const existing = (rawWindows ?? '').split('\n').filter(Boolean).map(line => {
|
|
156
|
+
const [windowId, windowName, cwd, paneCommand] = line.split('\t');
|
|
157
|
+
return { windowId, windowName, cwd, paneCommand };
|
|
158
|
+
}).filter((w) => w.windowName !== '_bridge_main');
|
|
159
|
+
const existingNames = new Set(existing.map(w => w.windowName));
|
|
160
|
+
let counter = 2;
|
|
161
|
+
while (existingNames.has(name)) {
|
|
162
|
+
name = `${opts.windowName}-${counter++}`;
|
|
163
|
+
}
|
|
164
|
+
// Issue #7: Retry window creation up to 3 times.
|
|
165
|
+
const MAX_RETRIES = 3;
|
|
166
|
+
let id = '';
|
|
167
|
+
let lastError = null;
|
|
168
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
169
|
+
try {
|
|
170
|
+
// Create the window
|
|
171
|
+
await this.tmuxInternal('new-window', '-t', this.sessionName, '-n', name, '-c', opts.workDir, '-d');
|
|
172
|
+
// Prevent CC from renaming the window
|
|
173
|
+
await this.tmuxInternal('set-window-option', '-t', `${this.sessionName}:${name}`, 'allow-rename', 'off');
|
|
174
|
+
// Issue #82: Set pane title to session name
|
|
175
|
+
await this.tmuxInternal('select-pane', '-t', `${this.sessionName}:${name}`, '-T', `aegis:${name}`);
|
|
176
|
+
// Get the window ID
|
|
177
|
+
const idRaw = await this.tmuxInternal('display-message', '-t', `${this.sessionName}:${name}`, '-p', '#{window_id}');
|
|
178
|
+
id = idRaw.trim();
|
|
179
|
+
// Verify the window actually exists after creation
|
|
180
|
+
const verifyRaw = await this.tmuxInternal('list-windows', '-t', this.sessionName, '-F', '#{window_id}\t#{window_name}\t#{pane_current_path}\t#{pane_current_command}');
|
|
181
|
+
const verified = verifyRaw.split('\n').filter(Boolean).some(line => {
|
|
182
|
+
const [wid] = line.split('\t');
|
|
183
|
+
return wid === id;
|
|
184
|
+
});
|
|
185
|
+
if (!verified) {
|
|
186
|
+
throw new Error(`Window ${name} (${id}) not found after creation`);
|
|
187
|
+
}
|
|
188
|
+
if (attempt > 1) {
|
|
189
|
+
console.log(`Tmux: window ${name} created on attempt ${attempt}`);
|
|
190
|
+
}
|
|
191
|
+
lastError = null;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
lastError = e;
|
|
196
|
+
console.error(`Tmux: createWindow attempt ${attempt}/${MAX_RETRIES} failed: ${e.message}`);
|
|
197
|
+
if (attempt < MAX_RETRIES) {
|
|
198
|
+
try {
|
|
199
|
+
await this.tmuxInternal('kill-window', '-t', `${this.sessionName}:${name}`);
|
|
200
|
+
}
|
|
201
|
+
catch { /* may not exist */ }
|
|
202
|
+
// Re-check session health inside the serialize scope
|
|
203
|
+
try {
|
|
204
|
+
await this.tmuxInternal('has-session', '-t', this.sessionName);
|
|
205
|
+
}
|
|
206
|
+
catch { /* session lost — recreate */
|
|
207
|
+
try {
|
|
208
|
+
await this.tmuxInternal('kill-session', '-t', this.sessionName);
|
|
209
|
+
}
|
|
210
|
+
catch { /* may not exist */ }
|
|
211
|
+
await this.tmuxInternal('new-session', '-d', '-s', this.sessionName, '-n', '_bridge_main', '-x', '220', '-y', '50');
|
|
212
|
+
}
|
|
213
|
+
await sleep(Math.min(500 * Math.pow(2, attempt), 5_000));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (lastError) {
|
|
218
|
+
throw new Error(`Failed to create tmux window after ${MAX_RETRIES} attempts: ${name} — ${lastError.message}`);
|
|
219
|
+
}
|
|
220
|
+
return { windowId: id, windowName: name };
|
|
221
|
+
});
|
|
222
|
+
windowId = creationResult.windowId;
|
|
223
|
+
finalName = creationResult.windowName;
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
this._creatingCount--;
|
|
227
|
+
}
|
|
228
|
+
// Set env vars if provided.
|
|
229
|
+
// Issue #89 L29: Recommended Claude Code environment variables.
|
|
230
|
+
// These are merged in SessionManager.createSession() from config.defaultSessionEnv
|
|
231
|
+
// and per-session opts.env (user vars override defaults).
|
|
232
|
+
//
|
|
233
|
+
// Key env vars CC recognizes:
|
|
234
|
+
// ANTHROPIC_API_KEY — Required for Anthropic API direct access
|
|
235
|
+
// ANTHROPIC_BASE_URL — Custom API endpoint (e.g. proxy/bedrock)
|
|
236
|
+
// ANTHROPIC_MODEL — Override default model selection
|
|
237
|
+
// DISABLE_AUTOUPDATER=1 — Suppress CC's auto-update check
|
|
238
|
+
// CLAUDE_CODE_SKIP_EULA=1 — Skip EULA prompt on first launch
|
|
239
|
+
// CLAUDE_CODE_USE_BEDROCK=1 — Use AWS Bedrock backend
|
|
240
|
+
// MCP_TIMEOUT_MS — Timeout for MCP server communication
|
|
241
|
+
// NO_COLOR=1 — Disable color output (useful for parsing)
|
|
242
|
+
// HOME — User home directory (usually inherited)
|
|
243
|
+
// PATH — Binary search path (usually inherited)
|
|
244
|
+
//
|
|
245
|
+
// Note: The --settings flag already handles proxy config (z.ai) and
|
|
246
|
+
// workspace trust, so ANTHROPIC_BASE_URL via env is a fallback.
|
|
247
|
+
//
|
|
248
|
+
// Issue #23: Use temp file + source instead of send-keys export to prevent
|
|
249
|
+
// env var values (tokens, secrets) from appearing in tmux pane history.
|
|
250
|
+
if (opts.env && Object.keys(opts.env).length > 0) {
|
|
251
|
+
await this.setEnvSecure(windowId, opts.env);
|
|
252
|
+
}
|
|
253
|
+
// Ensure Claude starts a fresh session.
|
|
254
|
+
// Two-layer defense against CC auto-resuming stale sessions:
|
|
255
|
+
//
|
|
256
|
+
// Layer 1 (primary): --session-id <fresh-uuid>
|
|
257
|
+
// Forces CC to create a new session with this ID instead of auto-resuming
|
|
258
|
+
// the latest .jsonl file. This is the reliable fix — no race conditions.
|
|
259
|
+
//
|
|
260
|
+
// Layer 2 (backup): archive old .jsonl files
|
|
261
|
+
// Moves existing session files to _archived/. Belt-and-suspenders —
|
|
262
|
+
// even if --session-id somehow fails, there's nothing to resume.
|
|
263
|
+
//
|
|
264
|
+
// History: v1 relied solely on archival, but had a race condition where CC
|
|
265
|
+
// could scan the directory before archival completed, resuming a stale session.
|
|
266
|
+
let freshSessionId;
|
|
267
|
+
if (!opts.resumeSessionId && !opts.claudeCommand) {
|
|
268
|
+
freshSessionId = crypto.randomUUID();
|
|
269
|
+
await this.archiveStaleSessionFiles(opts.workDir);
|
|
270
|
+
}
|
|
271
|
+
// Build the claude command
|
|
272
|
+
let cmd = opts.claudeCommand || 'claude';
|
|
273
|
+
if (opts.resumeSessionId) {
|
|
274
|
+
cmd += ` --resume ${opts.resumeSessionId}`;
|
|
275
|
+
}
|
|
276
|
+
else if (freshSessionId) {
|
|
277
|
+
cmd += ` --session-id ${freshSessionId}`;
|
|
278
|
+
}
|
|
279
|
+
// Set permission mode
|
|
280
|
+
// Resolve legacy autoApprove boolean to permissionMode string
|
|
281
|
+
const resolvedMode = opts.permissionMode
|
|
282
|
+
?? (opts.autoApprove === true ? 'bypassPermissions' : opts.autoApprove === false ? 'default' : undefined);
|
|
283
|
+
// Issue #89 L27: Warn when autoApprove is redundant with bypassPermissions.
|
|
284
|
+
// When permissionMode is already bypassPermissions, the autoApprove flag has
|
|
285
|
+
// no additional effect — both tell CC to skip all permission prompts.
|
|
286
|
+
if (opts.permissionMode === 'bypassPermissions' && opts.autoApprove === true) {
|
|
287
|
+
console.warn('Tmux: autoApprove=true is redundant with permissionMode=bypassPermissions — autoApprove has no additional effect');
|
|
288
|
+
}
|
|
289
|
+
if (resolvedMode) {
|
|
290
|
+
cmd += ` --permission-mode ${resolvedMode}`;
|
|
291
|
+
}
|
|
292
|
+
// Issue #169 Phase 2: Inject hook settings file if provided.
|
|
293
|
+
// This tells CC to POST hook events to Aegis's HTTP receiver.
|
|
294
|
+
// P0 fix: Always provide --settings to ensure CC loads proxy config
|
|
295
|
+
// (z.ai) and avoids workspace trust dialog in untrusted directories.
|
|
296
|
+
const settingsPath = opts.settingsFile
|
|
297
|
+
?? join(opts.workDir, '.claude', 'settings.local.json');
|
|
298
|
+
if (existsSync(settingsPath)) {
|
|
299
|
+
cmd += ` --settings ${shellEscape(settingsPath)}`;
|
|
300
|
+
}
|
|
301
|
+
// Issue #68: Unset $TMUX and $TMUX_PANE before launching Claude Code.
|
|
302
|
+
// If Aegis itself runs inside tmux, CC inherits these vars and:
|
|
303
|
+
// - Teammate spawns attempt split-pane in Aegis session (not isolated)
|
|
304
|
+
// - Color capabilities reduced to 256
|
|
305
|
+
// - Clipboard passthrough via tmux load-buffer instead of OSC 52
|
|
306
|
+
// Prefixing with 'unset' ensures CC gets a clean environment.
|
|
307
|
+
cmd = `unset TMUX TMUX_PANE && ${cmd}`;
|
|
308
|
+
// Send the command to start Claude
|
|
309
|
+
await this.sendKeys(windowId, cmd, true);
|
|
310
|
+
// Issue #7: Verify Claude process started by checking pane command.
|
|
311
|
+
// #357: Poll for pane command change instead of fixed 2s sleep.
|
|
312
|
+
// Zeus reported sessions where claude never started — byteOffset stayed 0 forever.
|
|
313
|
+
const CLAUDE_START_POLL_MS = 200;
|
|
314
|
+
const CLAUDE_START_TIMEOUT_MS = 3000;
|
|
315
|
+
const started = await this.pollUntil(async () => {
|
|
316
|
+
try {
|
|
317
|
+
const windows = await this.listWindows();
|
|
318
|
+
const win = windows.find(w => w.windowId === windowId);
|
|
319
|
+
if (!win)
|
|
320
|
+
return false;
|
|
321
|
+
const paneCmd = win.paneCommand.toLowerCase();
|
|
322
|
+
return paneCmd !== 'bash' && paneCmd !== 'zsh' && paneCmd !== 'sh';
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}, CLAUDE_START_POLL_MS, CLAUDE_START_TIMEOUT_MS);
|
|
328
|
+
if (!started) {
|
|
329
|
+
console.warn(`Tmux: Claude may not have started in ${finalName} — retrying...`);
|
|
330
|
+
try {
|
|
331
|
+
await this.sendKeys(windowId, cmd, true);
|
|
332
|
+
}
|
|
333
|
+
catch { /* best effort */ }
|
|
334
|
+
}
|
|
335
|
+
return { windowId, windowName: finalName, freshSessionId };
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Archive old Claude session files so interactive mode starts fresh.
|
|
339
|
+
*
|
|
340
|
+
* Claude CLI computes a project hash from the workDir path:
|
|
341
|
+
* /home/user/projects/foo → -home-user-projects-foo
|
|
342
|
+
* and stores sessions at ~/.claude/projects/<hash>/*.jsonl.
|
|
343
|
+
*
|
|
344
|
+
* In interactive mode, Claude always auto-resumes the latest .jsonl file.
|
|
345
|
+
* There is no CLI flag to disable this. The only reliable way to force a
|
|
346
|
+
* fresh session is to move existing .jsonl files out of the way.
|
|
347
|
+
*
|
|
348
|
+
* Files are moved to an `_archived/` subfolder (not deleted), so they can
|
|
349
|
+
* be recovered if needed.
|
|
350
|
+
*/
|
|
351
|
+
async archiveStaleSessionFiles(workDir) {
|
|
352
|
+
// Compute the project hash the same way Claude CLI does
|
|
353
|
+
const projectHash = '-' + workDir.replace(/^\//, '').replace(/\//g, '-');
|
|
354
|
+
const projectDir = join(homedir(), '.claude', 'projects', projectHash);
|
|
355
|
+
if (!existsSync(projectDir))
|
|
356
|
+
return;
|
|
357
|
+
try {
|
|
358
|
+
const files = await readdir(projectDir);
|
|
359
|
+
const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
|
|
360
|
+
if (jsonlFiles.length === 0)
|
|
361
|
+
return;
|
|
362
|
+
// Create archive dir
|
|
363
|
+
const archiveDir = join(projectDir, '_archived');
|
|
364
|
+
if (!existsSync(archiveDir)) {
|
|
365
|
+
await mkdir(archiveDir, { recursive: true });
|
|
366
|
+
}
|
|
367
|
+
// Move all .jsonl files to archive
|
|
368
|
+
for (const file of jsonlFiles) {
|
|
369
|
+
const src = join(projectDir, file);
|
|
370
|
+
const dst = join(archiveDir, `${Date.now()}-${file}`);
|
|
371
|
+
await fsRename(src, dst);
|
|
372
|
+
}
|
|
373
|
+
console.log(`Archived ${jsonlFiles.length} stale session file(s) for ${workDir}`);
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
// Non-fatal: if archiving fails, Claude may auto-resume but the session still works
|
|
377
|
+
console.warn(`Failed to archive stale sessions for ${workDir}:`, e);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** Issue #23: Set env vars securely without exposing values in tmux pane.
|
|
381
|
+
* Writes vars to a temp file, sources it, then deletes it.
|
|
382
|
+
* Values never appear in terminal scrollback or capture-pane output.
|
|
383
|
+
*/
|
|
384
|
+
async setEnvSecure(windowId, env) {
|
|
385
|
+
const fs = await import('node:fs/promises');
|
|
386
|
+
const path = await import('node:path');
|
|
387
|
+
// Validate env var keys before interpolation
|
|
388
|
+
for (const key of Object.keys(env)) {
|
|
389
|
+
if (!ENV_KEY_RE.test(key)) {
|
|
390
|
+
throw new Error(`Invalid env var key: '${key}' — must match ${ENV_KEY_RE.source}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Write env vars to a temp file with restrictive permissions.
|
|
394
|
+
// Use crypto.randomBytes for unpredictable path (not UUID slice).
|
|
395
|
+
const tmpFile = path.join(tmpdir(), `.aegis-env-${randomBytes(16).toString('hex')}`);
|
|
396
|
+
const lines = Object.entries(env).map(([key, val]) => {
|
|
397
|
+
// Escape single quotes in value
|
|
398
|
+
const escaped = val.replace(/'/g, "'\\''");
|
|
399
|
+
return `export ${key}='${escaped}'`;
|
|
400
|
+
});
|
|
401
|
+
await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
|
|
402
|
+
// Source the file and delete it — all in one command so the values
|
|
403
|
+
// appear in the process environment but not in the terminal history.
|
|
404
|
+
// The 'source' line is visible but only shows the temp file path, not the values.
|
|
405
|
+
await this.sendKeys(windowId, `source ${shellEscape(tmpFile)} && rm -f ${shellEscape(tmpFile)}`, true);
|
|
406
|
+
// #357: Brief poll for shell to process the source command (was fixed 500ms sleep)
|
|
407
|
+
await this.pollUntil(async () => { try {
|
|
408
|
+
await stat(tmpFile);
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return true;
|
|
413
|
+
} }, 50, 500);
|
|
414
|
+
// Belt and suspenders: delete the file from our side too
|
|
415
|
+
try {
|
|
416
|
+
await fs.unlink(tmpFile);
|
|
417
|
+
}
|
|
418
|
+
catch { /* already deleted by shell */ }
|
|
419
|
+
}
|
|
420
|
+
/** P1 fix: Check if a window exists. Returns true if window is in the session.
|
|
421
|
+
* #357: Uses a short-lived cache to avoid repeated tmux CLI calls. */
|
|
422
|
+
async windowExists(windowId) {
|
|
423
|
+
const now = Date.now();
|
|
424
|
+
const cached = this.windowCache.get(windowId);
|
|
425
|
+
if (cached && now - cached.timestamp < TmuxManager.WINDOW_CACHE_TTL_MS) {
|
|
426
|
+
return cached.exists;
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
const windows = await this.listWindows();
|
|
430
|
+
const exists = windows.some(w => w.windowId === windowId);
|
|
431
|
+
this.windowCache.set(windowId, { exists, timestamp: now });
|
|
432
|
+
return exists;
|
|
433
|
+
}
|
|
434
|
+
catch (e) {
|
|
435
|
+
console.warn(`Tmux: windowExists check failed for ${windowId}: ${e.message}`);
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/** Issue #69: Get the PID of the first pane in a window. Returns null on error. */
|
|
440
|
+
async listPanePid(windowId) {
|
|
441
|
+
try {
|
|
442
|
+
const target = `${this.sessionName}:${windowId}`;
|
|
443
|
+
const raw = await this.tmux('list-panes', '-t', target, '-F', '#{pane_pid}');
|
|
444
|
+
if (!raw)
|
|
445
|
+
return null;
|
|
446
|
+
const pid = parseInt(raw.split('\n')[0], 10);
|
|
447
|
+
return Number.isFinite(pid) ? pid : null;
|
|
448
|
+
}
|
|
449
|
+
catch (e) {
|
|
450
|
+
console.warn(`Tmux: listPanePid failed for ${windowId}: ${e.message}`);
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/** Issue #69: Check if a PID is alive using kill -0. */
|
|
455
|
+
isPidAlive(pid) {
|
|
456
|
+
try {
|
|
457
|
+
process.kill(pid, 0);
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
catch { /* ESRCH — process does not exist */
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/** Get detailed window info for health checks.
|
|
465
|
+
* Issue #2: Returns window existence, pane command, and whether Claude is running.
|
|
466
|
+
*/
|
|
467
|
+
async getWindowHealth(windowId) {
|
|
468
|
+
try {
|
|
469
|
+
const windows = await this.listWindows();
|
|
470
|
+
const win = windows.find(w => w.windowId === windowId);
|
|
471
|
+
if (!win) {
|
|
472
|
+
return { windowExists: false, paneCommand: null, claudeRunning: false };
|
|
473
|
+
}
|
|
474
|
+
const paneCmd = win.paneCommand.toLowerCase();
|
|
475
|
+
// Claude runs as 'claude' or 'node' process
|
|
476
|
+
const claudeRunning = paneCmd === 'claude' || paneCmd === 'node';
|
|
477
|
+
return { windowExists: true, paneCommand: win.paneCommand, claudeRunning };
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
console.warn(`Tmux: getWindowHealth failed for ${windowId}: ${e.message}`);
|
|
481
|
+
return { windowExists: false, paneCommand: null, claudeRunning: false };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** Send text to a window's active pane. */
|
|
485
|
+
async sendKeys(windowId, text, enter = true) {
|
|
486
|
+
// P1 fix: Verify window exists before sending keys
|
|
487
|
+
if (!(await this.windowExists(windowId))) {
|
|
488
|
+
throw new Error(`Tmux window ${windowId} does not exist — cannot send keys`);
|
|
489
|
+
}
|
|
490
|
+
const target = `${this.sessionName}:${windowId}`;
|
|
491
|
+
if (enter) {
|
|
492
|
+
// CC's ! command mode: send "!" first so the TUI switches to bash mode,
|
|
493
|
+
// then send the rest after TUI acknowledges the mode switch.
|
|
494
|
+
if (text.startsWith('!')) {
|
|
495
|
+
await this.tmux('send-keys', '-t', target, '-l', '!');
|
|
496
|
+
const rest = text.slice(1);
|
|
497
|
+
if (rest) {
|
|
498
|
+
// #357: Poll for `!` to be absorbed instead of fixed 1s sleep
|
|
499
|
+
await this.pollUntil(async () => {
|
|
500
|
+
try {
|
|
501
|
+
const pane = await this.capturePaneDirect(windowId);
|
|
502
|
+
return pane.includes('!');
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
}, 100, 1000);
|
|
508
|
+
await this.tmux('send-keys', '-t', target, '-l', rest);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
// Send text literally first (no Enter)
|
|
513
|
+
await this.tmux('send-keys', '-t', target, '-l', text);
|
|
514
|
+
}
|
|
515
|
+
// P2 fix: Short delay for tmux to register text before Enter
|
|
516
|
+
// #357: Reduced from 1000/2000ms to 200/500ms
|
|
517
|
+
const delay = text.length > 500 ? 500 : 200;
|
|
518
|
+
await sleep(delay);
|
|
519
|
+
// Send Enter
|
|
520
|
+
await this.tmux('send-keys', '-t', target, 'Enter');
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
await this.tmux('send-keys', '-t', target, '-l', text);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/** Check if a pane state indicates CC has received input (non-idle). */
|
|
527
|
+
isActiveState(state) {
|
|
528
|
+
return state === 'working' || state === 'permission_prompt' ||
|
|
529
|
+
state === 'bash_approval' || state === 'plan_mode' || state === 'ask_question';
|
|
530
|
+
}
|
|
531
|
+
/** Verify that a message was delivered to Claude Code.
|
|
532
|
+
* Issue #1 v2: Compares pre-send and post-send pane state to detect delivery.
|
|
533
|
+
*
|
|
534
|
+
* Strategy:
|
|
535
|
+
* 1. If CC transitioned from idle → active state → confirmed
|
|
536
|
+
* 2. If CC is in any active state (working, permission, etc.) → confirmed
|
|
537
|
+
* 3. If sent text (prefix) is visible in the pane → confirmed
|
|
538
|
+
* 4. If CC is still idle with no trace of input → NOT confirmed
|
|
539
|
+
* 5. Unknown state → benefit of the doubt (confirmed)
|
|
540
|
+
*
|
|
541
|
+
* The `preSendState` parameter enables state-change detection to avoid
|
|
542
|
+
* false negatives during transitional moments.
|
|
543
|
+
*/
|
|
544
|
+
async verifyDelivery(windowId, sentText, preSendState) {
|
|
545
|
+
const paneText = await this.capturePane(windowId);
|
|
546
|
+
const { detectUIState } = await import('./terminal-parser.js');
|
|
547
|
+
const state = detectUIState(paneText);
|
|
548
|
+
// Evidence 1: CC is in an active state — delivery confirmed
|
|
549
|
+
if (this.isActiveState(state)) {
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
// Evidence 2: State changed from idle to anything else (even unknown = transitioning)
|
|
553
|
+
if (preSendState === 'idle' && state !== 'idle') {
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
// Evidence 3: The sent text appears in the pane
|
|
557
|
+
const searchText = sentText.slice(0, 60).trim();
|
|
558
|
+
if (searchText.length >= 5 && paneText.includes(searchText)) {
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
// Evidence 4: Pane is clearly idle — delivery likely failed
|
|
562
|
+
if (state === 'idle') {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
// Unknown state — give benefit of the doubt
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
/** Send text and verify delivery with retry.
|
|
569
|
+
* Issue #1 v2: Captures pre-send state and only re-sends if pane is still idle.
|
|
570
|
+
* Prevents duplicate prompt delivery that plagued v1.
|
|
571
|
+
*/
|
|
572
|
+
async sendKeysVerified(windowId, text, maxAttempts = 3) {
|
|
573
|
+
const { detectUIState } = await import('./terminal-parser.js');
|
|
574
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
575
|
+
// Capture pane state BEFORE sending
|
|
576
|
+
const prePaneText = await this.capturePane(windowId);
|
|
577
|
+
const preState = detectUIState(prePaneText);
|
|
578
|
+
// Only send if pane is idle (or first attempt).
|
|
579
|
+
// If CC is already active/working, don't re-send — just verify.
|
|
580
|
+
if (attempt === 1 || preState === 'idle') {
|
|
581
|
+
if (attempt > 1) {
|
|
582
|
+
console.log(`Tmux: delivery retry ${attempt}/${maxAttempts} — pane is idle, re-sending`);
|
|
583
|
+
}
|
|
584
|
+
await this.sendKeys(windowId, text, true);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
// CC is not idle — it may have received the text but is in transition.
|
|
588
|
+
// Don't re-send (would duplicate the prompt).
|
|
589
|
+
console.log(`Tmux: delivery check ${attempt}/${maxAttempts} — pane is '${preState}', skipping re-send`);
|
|
590
|
+
}
|
|
591
|
+
// #357: Poll for delivery confirmation instead of graduated fixed sleeps.
|
|
592
|
+
// CC needs time to process input and transition states.
|
|
593
|
+
const pollInterval = 400;
|
|
594
|
+
const pollTimeout = attempt === 1 ? 5000 : 3000;
|
|
595
|
+
const delivered = await this.pollUntil(() => this.verifyDelivery(windowId, text, preState), pollInterval, pollTimeout);
|
|
596
|
+
if (delivered) {
|
|
597
|
+
if (attempt > 1) {
|
|
598
|
+
console.log(`Tmux: delivery confirmed on attempt ${attempt}`);
|
|
599
|
+
}
|
|
600
|
+
return { delivered: true, attempts: attempt };
|
|
601
|
+
}
|
|
602
|
+
if (attempt < maxAttempts) {
|
|
603
|
+
console.warn(`Tmux: delivery not confirmed for "${text.slice(0, 50)}..." (attempt ${attempt}/${maxAttempts})`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
console.error(`Tmux: delivery FAILED after ${maxAttempts} attempts for "${text.slice(0, 50)}..."`);
|
|
607
|
+
return { delivered: false, attempts: maxAttempts };
|
|
608
|
+
}
|
|
609
|
+
/** Send a special key (Escape, C-c, etc.) */
|
|
610
|
+
async sendSpecialKey(windowId, key) {
|
|
611
|
+
const target = `${this.sessionName}:${windowId}`;
|
|
612
|
+
await this.tmux('send-keys', '-t', target, key);
|
|
613
|
+
}
|
|
614
|
+
/** Capture the visible pane content.
|
|
615
|
+
* Issue #89 L23: Strips DCS passthrough sequences (ESC P ... ESC \\)
|
|
616
|
+
* that can leak through tmux's capture-pane into the output.
|
|
617
|
+
*/
|
|
618
|
+
async capturePane(windowId) {
|
|
619
|
+
const target = `${this.sessionName}:${windowId}`;
|
|
620
|
+
const raw = await this.tmux('capture-pane', '-t', target, '-p');
|
|
621
|
+
return raw.replace(/\x1bP[\s\S]*?\x1b\\/g, '');
|
|
622
|
+
}
|
|
623
|
+
/** Capture pane content WITHOUT going through the serialize queue.
|
|
624
|
+
* Used for critical-path operations (e.g., sendInitialPrompt) that should
|
|
625
|
+
* not be delayed by monitor polls. The queue is for preventing race conditions
|
|
626
|
+
* in monitor/concurrent reads, but sendInitialPrompt is the ONLY writer at
|
|
627
|
+
* session creation time.
|
|
628
|
+
* #403: During window creation (_creatingCount > 0), queues behind serialize
|
|
629
|
+
* to avoid racing with the creation sequence.
|
|
630
|
+
*/
|
|
631
|
+
async capturePaneDirect(windowId) {
|
|
632
|
+
if (this._creatingCount > 0) {
|
|
633
|
+
return this.serialize(() => this.capturePaneDirectInternal(windowId));
|
|
634
|
+
}
|
|
635
|
+
return this.capturePaneDirectInternal(windowId);
|
|
636
|
+
}
|
|
637
|
+
async capturePaneDirectInternal(windowId) {
|
|
638
|
+
const target = `${this.sessionName}:${windowId}`;
|
|
639
|
+
try {
|
|
640
|
+
const { stdout } = await execFileAsync('tmux', ['-L', this.socketName, 'capture-pane', '-t', target, '-p'], {
|
|
641
|
+
timeout: TMUX_DEFAULT_TIMEOUT_MS,
|
|
642
|
+
});
|
|
643
|
+
// Issue #89 L23: Strip DCS passthrough sequences
|
|
644
|
+
return stdout.trim().replace(/\x1bP[\s\S]*?\x1b\\/g, '');
|
|
645
|
+
}
|
|
646
|
+
catch (e) {
|
|
647
|
+
if (e && typeof e === 'object' && 'killed' in e && e.killed) {
|
|
648
|
+
throw new TmuxTimeoutError(['capture-pane', '-t', target, '-p'], TMUX_DEFAULT_TIMEOUT_MS);
|
|
649
|
+
}
|
|
650
|
+
throw e;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/** Send keys WITHOUT going through the serialize queue.
|
|
654
|
+
* Used for critical-path operations (e.g., sendInitialPrompt).
|
|
655
|
+
* Simplified version: sends literal text + Enter (no ! command mode handling).
|
|
656
|
+
* #403: During window creation (_creatingCount > 0), queues behind serialize
|
|
657
|
+
* to avoid racing with the creation sequence.
|
|
658
|
+
*/
|
|
659
|
+
async sendKeysDirect(windowId, text, enter = true) {
|
|
660
|
+
if (this._creatingCount > 0) {
|
|
661
|
+
return this.serialize(() => this.sendKeysDirectInternal(windowId, text, enter));
|
|
662
|
+
}
|
|
663
|
+
return this.sendKeysDirectInternal(windowId, text, enter);
|
|
664
|
+
}
|
|
665
|
+
async sendKeysDirectInternal(windowId, text, enter = true) {
|
|
666
|
+
const target = `${this.sessionName}:${windowId}`;
|
|
667
|
+
if (enter) {
|
|
668
|
+
await execFileAsync('tmux', ['-L', this.socketName, 'send-keys', '-t', target, '-l', text], {
|
|
669
|
+
timeout: TMUX_DEFAULT_TIMEOUT_MS,
|
|
670
|
+
});
|
|
671
|
+
// #357: Reduced adaptive delay (was 1000/2000ms)
|
|
672
|
+
const delay = text.length > 500 ? 500 : 200;
|
|
673
|
+
await new Promise(r => setTimeout(r, delay));
|
|
674
|
+
await execFileAsync('tmux', ['-L', this.socketName, 'send-keys', '-t', target, 'Enter'], {
|
|
675
|
+
timeout: TMUX_DEFAULT_TIMEOUT_MS,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
await execFileAsync('tmux', ['-L', this.socketName, 'send-keys', '-t', target, '-l', text], {
|
|
680
|
+
timeout: TMUX_DEFAULT_TIMEOUT_MS,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/** Resize a window's pane to the given dimensions. */
|
|
685
|
+
async resizePane(windowId, cols, rows) {
|
|
686
|
+
const target = `${this.sessionName}:${windowId}`;
|
|
687
|
+
await this.tmux('resize-pane', '-t', target, '-x', String(cols), '-y', String(rows));
|
|
688
|
+
}
|
|
689
|
+
/** Kill a window. */
|
|
690
|
+
async killWindow(windowId) {
|
|
691
|
+
const target = `${this.sessionName}:${windowId}`;
|
|
692
|
+
this.windowCache.delete(windowId);
|
|
693
|
+
try {
|
|
694
|
+
await this.tmux('kill-window', '-t', target);
|
|
695
|
+
}
|
|
696
|
+
catch (e) {
|
|
697
|
+
console.warn(`Tmux: killWindow failed for ${target}: ${e.message}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/** Kill the entire tmux session. Used for cleanup on shutdown. */
|
|
701
|
+
async killSession(sessionName) {
|
|
702
|
+
const target = sessionName ?? this.sessionName;
|
|
703
|
+
try {
|
|
704
|
+
await this.tmux('kill-session', '-t', target);
|
|
705
|
+
console.log(`Tmux: session '${target}' killed`);
|
|
706
|
+
}
|
|
707
|
+
catch (e) {
|
|
708
|
+
console.warn(`Tmux: killSession failed for '${target}': ${e.message}`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/** #357: Poll until condition returns true or timeout elapses. */
|
|
712
|
+
async pollUntil(condition, intervalMs, timeoutMs) {
|
|
713
|
+
const deadline = Date.now() + timeoutMs;
|
|
714
|
+
while (Date.now() < deadline) {
|
|
715
|
+
if (await condition())
|
|
716
|
+
return true;
|
|
717
|
+
await sleep(intervalMs);
|
|
718
|
+
}
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function sleep(ms) {
|
|
723
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
724
|
+
}
|
|
725
|
+
//# sourceMappingURL=tmux.js.map
|