codepiper 0.1.0
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/.env.example +28 -0
- package/CHANGELOG.md +10 -0
- package/LEGAL_NOTICE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/package.json +90 -0
- package/packages/cli/package.json +13 -0
- package/packages/cli/src/commands/analytics.ts +157 -0
- package/packages/cli/src/commands/attach.ts +299 -0
- package/packages/cli/src/commands/audit.ts +50 -0
- package/packages/cli/src/commands/auth.ts +261 -0
- package/packages/cli/src/commands/daemon.ts +162 -0
- package/packages/cli/src/commands/doctor.ts +303 -0
- package/packages/cli/src/commands/env-set.ts +162 -0
- package/packages/cli/src/commands/hook-forward.ts +268 -0
- package/packages/cli/src/commands/keys.ts +77 -0
- package/packages/cli/src/commands/kill.ts +19 -0
- package/packages/cli/src/commands/logs.ts +419 -0
- package/packages/cli/src/commands/model.ts +172 -0
- package/packages/cli/src/commands/policy-set.ts +185 -0
- package/packages/cli/src/commands/policy.ts +227 -0
- package/packages/cli/src/commands/providers.ts +114 -0
- package/packages/cli/src/commands/resize.ts +34 -0
- package/packages/cli/src/commands/send.ts +184 -0
- package/packages/cli/src/commands/sessions.ts +202 -0
- package/packages/cli/src/commands/slash.ts +92 -0
- package/packages/cli/src/commands/start.ts +243 -0
- package/packages/cli/src/commands/stop.ts +19 -0
- package/packages/cli/src/commands/tail.ts +137 -0
- package/packages/cli/src/commands/workflow.ts +786 -0
- package/packages/cli/src/commands/workspace.ts +127 -0
- package/packages/cli/src/lib/api.ts +78 -0
- package/packages/cli/src/lib/args.ts +72 -0
- package/packages/cli/src/lib/format.ts +93 -0
- package/packages/cli/src/main.ts +563 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/config.ts +30 -0
- package/packages/core/src/errors.ts +38 -0
- package/packages/core/src/eventBus.ts +56 -0
- package/packages/core/src/eventBusAdapter.ts +143 -0
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/sqliteEventBus.ts +336 -0
- package/packages/core/src/types.ts +63 -0
- package/packages/daemon/package.json +11 -0
- package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
- package/packages/daemon/src/api/authRoutes.ts +344 -0
- package/packages/daemon/src/api/bodyLimit.ts +133 -0
- package/packages/daemon/src/api/envSetRoutes.ts +170 -0
- package/packages/daemon/src/api/gitRoutes.ts +409 -0
- package/packages/daemon/src/api/hooks.ts +588 -0
- package/packages/daemon/src/api/inputPolicy.ts +249 -0
- package/packages/daemon/src/api/notificationRoutes.ts +532 -0
- package/packages/daemon/src/api/policyRoutes.ts +234 -0
- package/packages/daemon/src/api/policySetRoutes.ts +445 -0
- package/packages/daemon/src/api/routeUtils.ts +28 -0
- package/packages/daemon/src/api/routes.ts +1004 -0
- package/packages/daemon/src/api/server.ts +1388 -0
- package/packages/daemon/src/api/settingsRoutes.ts +367 -0
- package/packages/daemon/src/api/sqliteErrors.ts +47 -0
- package/packages/daemon/src/api/stt.ts +143 -0
- package/packages/daemon/src/api/terminalRoutes.ts +200 -0
- package/packages/daemon/src/api/validation.ts +287 -0
- package/packages/daemon/src/api/validationRoutes.ts +174 -0
- package/packages/daemon/src/api/workflowRoutes.ts +567 -0
- package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
- package/packages/daemon/src/api/ws.ts +1588 -0
- package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
- package/packages/daemon/src/auth/authMiddleware.ts +305 -0
- package/packages/daemon/src/auth/authService.ts +496 -0
- package/packages/daemon/src/auth/rateLimiter.ts +137 -0
- package/packages/daemon/src/config/pricing.ts +79 -0
- package/packages/daemon/src/crypto/encryption.ts +196 -0
- package/packages/daemon/src/db/db.ts +2745 -0
- package/packages/daemon/src/db/index.ts +16 -0
- package/packages/daemon/src/db/migrations.ts +182 -0
- package/packages/daemon/src/db/policyDb.ts +349 -0
- package/packages/daemon/src/db/schema.sql +408 -0
- package/packages/daemon/src/db/workflowDb.ts +464 -0
- package/packages/daemon/src/git/gitUtils.ts +544 -0
- package/packages/daemon/src/index.ts +6 -0
- package/packages/daemon/src/main.ts +525 -0
- package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
- package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
- package/packages/daemon/src/providers/registry.ts +111 -0
- package/packages/daemon/src/providers/types.ts +82 -0
- package/packages/daemon/src/sessions/auditLogger.ts +103 -0
- package/packages/daemon/src/sessions/policyEngine.ts +165 -0
- package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
- package/packages/daemon/src/sessions/policyTypes.ts +94 -0
- package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
- package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
- package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
- package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
- package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
- package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
- package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
- package/packages/daemon/src/workflows/contextManager.ts +83 -0
- package/packages/daemon/src/workflows/index.ts +31 -0
- package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
- package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
- package/packages/daemon/src/workflows/workflowParser.ts +217 -0
- package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
- package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
- package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
- package/packages/providers/claude-code/package.json +11 -0
- package/packages/providers/claude-code/src/index.ts +7 -0
- package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
- package/packages/providers/claude-code/src/provider.ts +311 -0
- package/packages/web/dist/android-chrome-192x192.png +0 -0
- package/packages/web/dist/android-chrome-512x512.png +0 -0
- package/packages/web/dist/apple-touch-icon.png +0 -0
- package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
- package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
- package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
- package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
- package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
- package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
- package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
- package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
- package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
- package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
- package/packages/web/dist/assets/index-hgphORiw.js +204 -0
- package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
- package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
- package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
- package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
- package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
- package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
- package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
- package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
- package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
- package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
- package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
- package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
- package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/icon.svg +1 -0
- package/packages/web/dist/index.html +29 -0
- package/packages/web/dist/manifest.json +29 -0
- package/packages/web/dist/og-image.png +0 -0
- package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
- package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
- package/packages/web/dist/originals/apple-touch-icon.png +0 -0
- package/packages/web/dist/originals/favicon.ico +0 -0
- package/packages/web/dist/piper.svg +1 -0
- package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
- package/packages/web/dist/sw.js +257 -0
- package/scripts/postinstall-link-workspaces.mjs +58 -0
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TmuxSession - manages a tmux session for true TTY support
|
|
3
|
+
*
|
|
4
|
+
* Provides real terminal for applications like Claude Code that rely on
|
|
5
|
+
* physical keyboard input vs programmatic PTY input (Ink library limitation).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
11
|
+
export type TerminalMode = "interactive" | "scroll" | "search";
|
|
12
|
+
|
|
13
|
+
const SAFE_FALLBACK_ENV_KEYS = [
|
|
14
|
+
"PATH",
|
|
15
|
+
"HOME",
|
|
16
|
+
"USER",
|
|
17
|
+
"SHELL",
|
|
18
|
+
"TERM",
|
|
19
|
+
"LANG",
|
|
20
|
+
"LC_ALL",
|
|
21
|
+
"LC_CTYPE",
|
|
22
|
+
"TMPDIR",
|
|
23
|
+
"TZ",
|
|
24
|
+
];
|
|
25
|
+
const SAFE_ENV_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
26
|
+
|
|
27
|
+
export interface TerminalInfo {
|
|
28
|
+
mode: TerminalMode;
|
|
29
|
+
cols: number;
|
|
30
|
+
rows: number;
|
|
31
|
+
scrollPosition?: number;
|
|
32
|
+
historySize?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TerminalCursor {
|
|
36
|
+
x: number;
|
|
37
|
+
y: number;
|
|
38
|
+
visible: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TmuxSessionOptions {
|
|
42
|
+
sessionName: string;
|
|
43
|
+
command: string[];
|
|
44
|
+
cwd: string;
|
|
45
|
+
env: Record<string, string>;
|
|
46
|
+
cols?: number;
|
|
47
|
+
rows?: number;
|
|
48
|
+
historyLimit?: number;
|
|
49
|
+
onData?: (data: string, cursor?: TerminalCursor) => void;
|
|
50
|
+
onExit?: (exitCode: number, signal: string | null) => void;
|
|
51
|
+
onModeChange?: (mode: TerminalMode) => void;
|
|
52
|
+
outputLogPath?: string; // Path to log all session output
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface PaneSnapshot {
|
|
56
|
+
content: string;
|
|
57
|
+
cursor: TerminalCursor | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class TmuxSession {
|
|
61
|
+
private sessionName: string;
|
|
62
|
+
private command: string[];
|
|
63
|
+
private cwd: string;
|
|
64
|
+
private env: Record<string, string>;
|
|
65
|
+
private cols: number;
|
|
66
|
+
private rows: number;
|
|
67
|
+
private historyLimit: number;
|
|
68
|
+
private onDataCallback?: (data: string, cursor?: TerminalCursor) => void;
|
|
69
|
+
private onExitCallback?: (exitCode: number, signal: string | null) => void;
|
|
70
|
+
private pollTimeout?: Timer;
|
|
71
|
+
private monitorInterval?: Timer;
|
|
72
|
+
private lastContent = "";
|
|
73
|
+
private consecutiveUnchanged = 0;
|
|
74
|
+
private consecutiveErrors = 0;
|
|
75
|
+
private outputLogPath?: string;
|
|
76
|
+
private writeBuffer = "";
|
|
77
|
+
private writeTimer?: Timer;
|
|
78
|
+
private _closed = false;
|
|
79
|
+
private _exitCallbackFired = false;
|
|
80
|
+
private _pid?: number;
|
|
81
|
+
private _mode: TerminalMode = "interactive";
|
|
82
|
+
private onModeChangeCallback?: (mode: TerminalMode) => void;
|
|
83
|
+
private pollCount = 0;
|
|
84
|
+
private lastPollTime = 0;
|
|
85
|
+
private lastCursor: TerminalCursor | null = null;
|
|
86
|
+
private cursorMarkerSeq = 0;
|
|
87
|
+
|
|
88
|
+
constructor(options: TmuxSessionOptions) {
|
|
89
|
+
this.sessionName = options.sessionName;
|
|
90
|
+
this.command = options.command;
|
|
91
|
+
this.cwd = options.cwd;
|
|
92
|
+
this.env = options.env;
|
|
93
|
+
this.cols = options.cols ?? 120;
|
|
94
|
+
this.rows = options.rows ?? 30;
|
|
95
|
+
this.historyLimit = options.historyLimit ?? 50000;
|
|
96
|
+
this.onDataCallback = options.onData;
|
|
97
|
+
this.onExitCallback = options.onExit;
|
|
98
|
+
this.onModeChangeCallback = options.onModeChange;
|
|
99
|
+
this.outputLogPath = options.outputLogPath;
|
|
100
|
+
|
|
101
|
+
// Ensure output log directory exists with restrictive permissions
|
|
102
|
+
if (this.outputLogPath) {
|
|
103
|
+
const dir = path.dirname(this.outputLogPath);
|
|
104
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
105
|
+
try {
|
|
106
|
+
fs.chmodSync(dir, 0o700);
|
|
107
|
+
} catch {
|
|
108
|
+
// best-effort on non-POSIX filesystems
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Pre-create log file with owner-only permissions.
|
|
112
|
+
const fd = fs.openSync(this.outputLogPath, "a", 0o600);
|
|
113
|
+
fs.closeSync(fd);
|
|
114
|
+
try {
|
|
115
|
+
fs.chmodSync(this.outputLogPath, 0o600);
|
|
116
|
+
} catch {
|
|
117
|
+
// best-effort on non-POSIX filesystems
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get closed(): boolean {
|
|
123
|
+
return this._closed;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get pid(): number | undefined {
|
|
127
|
+
return this._pid;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get mode(): TerminalMode {
|
|
131
|
+
return this._mode;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Create and start the tmux session
|
|
136
|
+
*/
|
|
137
|
+
async create(): Promise<void> {
|
|
138
|
+
// Build tmux command arguments
|
|
139
|
+
const args = [
|
|
140
|
+
"new-session",
|
|
141
|
+
"-d", // detached
|
|
142
|
+
"-s",
|
|
143
|
+
this.sessionName,
|
|
144
|
+
"-x",
|
|
145
|
+
this.cols.toString(),
|
|
146
|
+
"-y",
|
|
147
|
+
this.rows.toString(),
|
|
148
|
+
"-c",
|
|
149
|
+
this.cwd, // working directory
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
// Execute command under a clean environment to avoid inheriting
|
|
153
|
+
// potentially sensitive variables from the tmux server process.
|
|
154
|
+
// Include safe fallbacks so direct TmuxSession usage (tests, tools)
|
|
155
|
+
// still has PATH/HOME/etc. when not provided explicitly.
|
|
156
|
+
const commandEnv: Record<string, string> = {};
|
|
157
|
+
for (const key of SAFE_FALLBACK_ENV_KEYS) {
|
|
158
|
+
const value = this.env[key] ?? process.env[key];
|
|
159
|
+
if (value !== undefined) {
|
|
160
|
+
commandEnv[key] = value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const [key, value] of Object.entries(this.env)) {
|
|
164
|
+
commandEnv[key] = value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const envArgs: string[] = [];
|
|
168
|
+
for (const [key, value] of Object.entries(commandEnv)) {
|
|
169
|
+
if (!SAFE_ENV_NAME.test(key)) {
|
|
170
|
+
throw new Error(`Invalid environment variable name: ${key}`);
|
|
171
|
+
}
|
|
172
|
+
envArgs.push(`${key}=${value}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Use env -i so only explicitly provided vars are visible to the session process.
|
|
176
|
+
args.push("env", "-i", ...envArgs, ...this.command);
|
|
177
|
+
|
|
178
|
+
// Create tmux session
|
|
179
|
+
await this.runTmux(args);
|
|
180
|
+
|
|
181
|
+
// Wait for session to be responsive
|
|
182
|
+
await this.waitForSession();
|
|
183
|
+
|
|
184
|
+
// Configure session options (best-effort — don't fail create on these)
|
|
185
|
+
try {
|
|
186
|
+
await this.runTmux([
|
|
187
|
+
"set-option",
|
|
188
|
+
"-t",
|
|
189
|
+
this.sessionName,
|
|
190
|
+
"history-limit",
|
|
191
|
+
this.historyLimit.toString(),
|
|
192
|
+
]);
|
|
193
|
+
} catch {
|
|
194
|
+
// history-limit is best-effort
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Keep session alive after process exits so we can read the real exit code
|
|
198
|
+
// via #{pane_dead_status} before cleaning up
|
|
199
|
+
try {
|
|
200
|
+
await this.runTmux(["set-option", "-t", this.sessionName, "remain-on-exit", "on"]);
|
|
201
|
+
} catch {
|
|
202
|
+
// remain-on-exit is best-effort
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Get the PID of the process running in tmux
|
|
206
|
+
try {
|
|
207
|
+
const pidOutput = await this.runTmux([
|
|
208
|
+
"list-panes",
|
|
209
|
+
"-t",
|
|
210
|
+
this.sessionName,
|
|
211
|
+
"-F",
|
|
212
|
+
"#{pane_pid}",
|
|
213
|
+
]);
|
|
214
|
+
this._pid = Number.parseInt(pidOutput.trim(), 10);
|
|
215
|
+
} catch {
|
|
216
|
+
// PID retrieval is best-effort
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Start streaming output if callback provided
|
|
220
|
+
if (this.onDataCallback) {
|
|
221
|
+
await this.startOutputStreaming();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Monitor session for exit
|
|
225
|
+
this.monitorSessionExit();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Adopt an existing tmux session (skip creation).
|
|
230
|
+
* Used to re-attach to orphaned sessions after daemon restart.
|
|
231
|
+
* Starts output polling and exit monitoring without creating a new tmux session.
|
|
232
|
+
*/
|
|
233
|
+
async adopt(): Promise<void> {
|
|
234
|
+
// Verify session exists
|
|
235
|
+
await this.waitForSession();
|
|
236
|
+
|
|
237
|
+
// Ensure remain-on-exit is set for adopted sessions too
|
|
238
|
+
try {
|
|
239
|
+
await this.runTmux(["set-option", "-t", this.sessionName, "remain-on-exit", "on"]);
|
|
240
|
+
} catch {
|
|
241
|
+
// best-effort
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Get the PID of the process running in tmux
|
|
245
|
+
try {
|
|
246
|
+
const pidOutput = await this.runTmux([
|
|
247
|
+
"list-panes",
|
|
248
|
+
"-t",
|
|
249
|
+
this.sessionName,
|
|
250
|
+
"-F",
|
|
251
|
+
"#{pane_pid}",
|
|
252
|
+
]);
|
|
253
|
+
this._pid = Number.parseInt(pidOutput.trim(), 10);
|
|
254
|
+
} catch {
|
|
255
|
+
// PID retrieval is best-effort
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Start streaming output if callback provided
|
|
259
|
+
if (this.onDataCallback) {
|
|
260
|
+
await this.startOutputStreaming();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Monitor session for exit
|
|
264
|
+
this.monitorSessionExit();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Detach from the tmux session without killing it.
|
|
269
|
+
* Stops output polling and exit monitoring, allowing the daemon to
|
|
270
|
+
* shut down while the tmux session continues running.
|
|
271
|
+
*/
|
|
272
|
+
detach(): void {
|
|
273
|
+
this.stopOutputStreaming();
|
|
274
|
+
|
|
275
|
+
if (this.monitorInterval) {
|
|
276
|
+
clearInterval(this.monitorInterval);
|
|
277
|
+
this.monitorInterval = undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.flush();
|
|
281
|
+
if (this.writeTimer) {
|
|
282
|
+
clearTimeout(this.writeTimer);
|
|
283
|
+
this.writeTimer = undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this._closed = true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Wait for tmux session to be responsive
|
|
291
|
+
*/
|
|
292
|
+
private async waitForSession(timeoutMs = 5000): Promise<void> {
|
|
293
|
+
const startTime = Date.now();
|
|
294
|
+
|
|
295
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
296
|
+
const proc = Bun.spawn(["tmux", "has-session", "-t", this.sessionName], {
|
|
297
|
+
stdout: "ignore",
|
|
298
|
+
stderr: "ignore",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if ((await proc.exited) === 0) {
|
|
302
|
+
return; // Session exists and is responsive
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
throw new Error(`Session ${this.sessionName} did not become ready within ${timeoutMs}ms`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Stream output from tmux session using adaptive polling.
|
|
313
|
+
* Uses recursive setTimeout instead of setInterval so the poll rate
|
|
314
|
+
* can slow down when idle and speed up during active output.
|
|
315
|
+
*/
|
|
316
|
+
private async startOutputStreaming(): Promise<void> {
|
|
317
|
+
this.consecutiveUnchanged = 0;
|
|
318
|
+
this.consecutiveErrors = 0;
|
|
319
|
+
this.schedulePoll();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private schedulePoll(): void {
|
|
323
|
+
if (this._closed) return;
|
|
324
|
+
|
|
325
|
+
let delay: number;
|
|
326
|
+
if (this.consecutiveErrors > 0) {
|
|
327
|
+
// Error backoff: 1s, 2s, 4s, 8s, max 30s
|
|
328
|
+
delay = Math.min(1000 * 2 ** (this.consecutiveErrors - 1), 30000);
|
|
329
|
+
} else if (this.consecutiveUnchanged >= 20) {
|
|
330
|
+
delay = 500; // Idle (2+ seconds unchanged) — save CPU
|
|
331
|
+
} else if (this.consecutiveUnchanged >= 5) {
|
|
332
|
+
delay = 200; // Settling — moderate
|
|
333
|
+
} else {
|
|
334
|
+
delay = 100; // Active — responsive
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.pollTimeout = setTimeout(() => this.pollOutput(), delay);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private async pollOutput(): Promise<void> {
|
|
341
|
+
if (this._closed) return;
|
|
342
|
+
this.lastPollTime = Date.now();
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const { content, cursor } = await this.capturePaneSnapshot();
|
|
346
|
+
if (this._closed) return; // Session killed during capture
|
|
347
|
+
|
|
348
|
+
this.consecutiveErrors = 0; // Reset on success
|
|
349
|
+
this.pollCount++;
|
|
350
|
+
|
|
351
|
+
// Periodically sync mode state with tmux reality (every 10th poll)
|
|
352
|
+
if (this._mode !== "interactive" && this.pollCount % 10 === 0) {
|
|
353
|
+
try {
|
|
354
|
+
const modeInfo = await this.runTmux([
|
|
355
|
+
"display-message",
|
|
356
|
+
"-t",
|
|
357
|
+
this.sessionName,
|
|
358
|
+
"-p",
|
|
359
|
+
"#{pane_in_mode}",
|
|
360
|
+
]);
|
|
361
|
+
if (modeInfo.trim() === "0") {
|
|
362
|
+
this._mode = "interactive";
|
|
363
|
+
this.onModeChangeCallback?.("interactive");
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// mode sync is best-effort
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Track both content and cursor changes.
|
|
371
|
+
// Some TUIs move the cursor without mutating visible text.
|
|
372
|
+
const contentChanged = content !== this.lastContent;
|
|
373
|
+
const cursorChanged = !this.isSameCursor(cursor, this.lastCursor);
|
|
374
|
+
|
|
375
|
+
if (contentChanged || cursorChanged) {
|
|
376
|
+
this.consecutiveUnchanged = 0;
|
|
377
|
+
if (contentChanged) {
|
|
378
|
+
this.lastContent = content;
|
|
379
|
+
}
|
|
380
|
+
this.lastCursor = cursor;
|
|
381
|
+
|
|
382
|
+
if (this.outputLogPath && contentChanged) {
|
|
383
|
+
await fs.promises.writeFile(this.outputLogPath, content, {
|
|
384
|
+
encoding: "utf-8",
|
|
385
|
+
mode: 0o600,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
this.onDataCallback?.(content, cursor ?? undefined);
|
|
390
|
+
} else {
|
|
391
|
+
this.consecutiveUnchanged++;
|
|
392
|
+
}
|
|
393
|
+
} catch (err) {
|
|
394
|
+
if (!this._closed) {
|
|
395
|
+
this.consecutiveErrors++;
|
|
396
|
+
console.error(
|
|
397
|
+
`Output polling error for ${this.sessionName} (${this.consecutiveErrors} consecutive):`,
|
|
398
|
+
err
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
// After 2+ consecutive errors, session is likely gone — check immediately
|
|
402
|
+
if (this.consecutiveErrors >= 2) {
|
|
403
|
+
this.checkSessionAlive();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.schedulePoll();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private parseCursorParts(
|
|
412
|
+
xRaw: string | undefined,
|
|
413
|
+
yRaw: string | undefined,
|
|
414
|
+
visibleRaw: string | undefined
|
|
415
|
+
): TerminalCursor | null {
|
|
416
|
+
const x = Number.parseInt(xRaw ?? "", 10);
|
|
417
|
+
const y = Number.parseInt(yRaw ?? "", 10);
|
|
418
|
+
if (Number.isNaN(x) || Number.isNaN(y)) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
x: Math.max(0, x),
|
|
423
|
+
y: Math.max(0, y),
|
|
424
|
+
visible: visibleRaw !== "0",
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private isSameCursor(a: TerminalCursor | null, b: TerminalCursor | null): boolean {
|
|
429
|
+
if (a === b) return true;
|
|
430
|
+
if (!(a && b)) return false;
|
|
431
|
+
return a.x === b.x && a.y === b.y && a.visible === b.visible;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Stop output streaming
|
|
436
|
+
*/
|
|
437
|
+
private stopOutputStreaming(): void {
|
|
438
|
+
if (this.pollTimeout) {
|
|
439
|
+
clearTimeout(this.pollTimeout);
|
|
440
|
+
this.pollTimeout = undefined;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Ensure the polling loop is running. Restarts it if it appears to have stopped.
|
|
446
|
+
* Called from scroll/output endpoints as a self-healing mechanism.
|
|
447
|
+
*/
|
|
448
|
+
ensurePolling(): void {
|
|
449
|
+
if (this._closed || !this.onDataCallback) return;
|
|
450
|
+
|
|
451
|
+
// If no poll has fired in the last 5 seconds, the loop has likely died
|
|
452
|
+
const staleMs = Date.now() - this.lastPollTime;
|
|
453
|
+
if (staleMs > 5000 && !this.pollTimeout) {
|
|
454
|
+
console.warn(
|
|
455
|
+
`[TmuxSession] Polling stale for ${this.sessionName} (${staleMs}ms) — restarting`
|
|
456
|
+
);
|
|
457
|
+
this.consecutiveUnchanged = 0;
|
|
458
|
+
this.consecutiveErrors = 0;
|
|
459
|
+
this.schedulePoll();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Quick check if session is still alive. Triggers exit handling if not.
|
|
465
|
+
* Called from polling error path for faster detection of externally killed sessions.
|
|
466
|
+
*/
|
|
467
|
+
private checkSessionAlive(): void {
|
|
468
|
+
const proc = Bun.spawn(["tmux", "has-session", "-t", this.sessionName], {
|
|
469
|
+
stdout: "ignore",
|
|
470
|
+
stderr: "ignore",
|
|
471
|
+
});
|
|
472
|
+
proc.exited.then((code) => {
|
|
473
|
+
if (code !== 0 && !this._closed) {
|
|
474
|
+
this.handleSessionExit(1, null);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Monitor tmux session and detect when it exits.
|
|
481
|
+
* Uses pane_dead + pane_dead_status to capture real exit codes.
|
|
482
|
+
* Requires remain-on-exit=on (set in create/adopt) so the session
|
|
483
|
+
* persists after the process dies, giving us time to read the status.
|
|
484
|
+
*/
|
|
485
|
+
private monitorSessionExit(): void {
|
|
486
|
+
this.monitorInterval = setInterval(async () => {
|
|
487
|
+
try {
|
|
488
|
+
// Check if the pane process is dead (more precise than session gone)
|
|
489
|
+
const info = await this.runTmux([
|
|
490
|
+
"display-message",
|
|
491
|
+
"-t",
|
|
492
|
+
this.sessionName,
|
|
493
|
+
"-p",
|
|
494
|
+
"#{pane_dead}:#{pane_dead_status}",
|
|
495
|
+
]);
|
|
496
|
+
const [dead, status] = info.trim().split(":");
|
|
497
|
+
if (dead === "1") {
|
|
498
|
+
const exitCode = Number.parseInt(status ?? "1", 10);
|
|
499
|
+
// Kill the dead session (remain-on-exit keeps it alive)
|
|
500
|
+
this.killTmuxSession().catch(() => {
|
|
501
|
+
// best-effort cleanup of dead session
|
|
502
|
+
});
|
|
503
|
+
this.handleSessionExit(Number.isNaN(exitCode) ? 1 : exitCode, null);
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
} catch {
|
|
507
|
+
// display-message failed — session may be gone entirely
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Fallback: check if session exists at all
|
|
511
|
+
const proc = Bun.spawn(["tmux", "has-session", "-t", this.sessionName], {
|
|
512
|
+
stdout: "ignore",
|
|
513
|
+
stderr: "ignore",
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const exitCode = await proc.exited;
|
|
517
|
+
|
|
518
|
+
if (exitCode !== 0) {
|
|
519
|
+
// Session no longer exists — assume non-zero exit
|
|
520
|
+
this.handleSessionExit(1, null);
|
|
521
|
+
}
|
|
522
|
+
}, 500); // Check twice per second for responsive exit detection
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Handle session exit and cleanup
|
|
527
|
+
*/
|
|
528
|
+
private handleSessionExit(exitCode: number, signal: string | null): void {
|
|
529
|
+
if (this._closed) return;
|
|
530
|
+
|
|
531
|
+
this._closed = true;
|
|
532
|
+
|
|
533
|
+
// Clean up all timers and processes
|
|
534
|
+
if (this.monitorInterval) {
|
|
535
|
+
clearInterval(this.monitorInterval);
|
|
536
|
+
this.monitorInterval = undefined;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (this.writeTimer) {
|
|
540
|
+
clearTimeout(this.writeTimer);
|
|
541
|
+
this.writeTimer = undefined;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
this.stopOutputStreaming();
|
|
545
|
+
|
|
546
|
+
this.fireExitCallback(exitCode, signal);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Fire the exit callback exactly once (guard against double-fire in kill + monitor race)
|
|
551
|
+
*/
|
|
552
|
+
private fireExitCallback(exitCode: number, signal: string | null): void {
|
|
553
|
+
if (this._exitCallbackFired) return;
|
|
554
|
+
this._exitCallbackFired = true;
|
|
555
|
+
|
|
556
|
+
if (this.onExitCallback) {
|
|
557
|
+
this.onExitCallback(exitCode, signal);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Send text input to the tmux session
|
|
563
|
+
* Batches rapid writes with 10ms debounce for better performance
|
|
564
|
+
*/
|
|
565
|
+
write(data: string): void {
|
|
566
|
+
if (this._closed) {
|
|
567
|
+
throw new Error("Cannot write to closed tmux session");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Add to buffer
|
|
571
|
+
this.writeBuffer += data;
|
|
572
|
+
|
|
573
|
+
// Clear existing timer
|
|
574
|
+
if (this.writeTimer) {
|
|
575
|
+
clearTimeout(this.writeTimer);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Schedule flush with debounce
|
|
579
|
+
this.writeTimer = setTimeout(() => this.flushWrites(), 10);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Flush batched writes to tmux
|
|
584
|
+
*/
|
|
585
|
+
private flushWrites(): void {
|
|
586
|
+
if (!this.writeBuffer) return;
|
|
587
|
+
|
|
588
|
+
const data = this.writeBuffer;
|
|
589
|
+
this.writeBuffer = "";
|
|
590
|
+
this.writeTimer = undefined;
|
|
591
|
+
|
|
592
|
+
// Send batched data
|
|
593
|
+
const proc = Bun.spawn(["tmux", "send-keys", "-t", this.sessionName, "-l", data], {
|
|
594
|
+
stdout: "ignore",
|
|
595
|
+
stderr: "pipe",
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// Check for errors asynchronously (don't block)
|
|
599
|
+
proc.exited
|
|
600
|
+
.then((exitCode) => {
|
|
601
|
+
if (exitCode !== 0) {
|
|
602
|
+
console.error(
|
|
603
|
+
`tmux send-keys failed with exit code ${exitCode} for session ${this.sessionName}`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
.catch((err) => {
|
|
608
|
+
console.error(`tmux send-keys error for session ${this.sessionName}:`, err);
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Flush any pending writes immediately (synchronous — fire-and-forget)
|
|
614
|
+
*/
|
|
615
|
+
flush(): void {
|
|
616
|
+
if (this.writeTimer) {
|
|
617
|
+
clearTimeout(this.writeTimer);
|
|
618
|
+
this.writeTimer = undefined;
|
|
619
|
+
}
|
|
620
|
+
this.flushWrites();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Send a key sequence (like Enter, Ctrl+C, etc.)
|
|
625
|
+
*/
|
|
626
|
+
async sendKey(key: string): Promise<void> {
|
|
627
|
+
if (this._closed) {
|
|
628
|
+
throw new Error("Cannot send key to closed tmux session");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// tmux send-keys without -l interprets special keys
|
|
632
|
+
await this.runTmux(["send-keys", "-t", this.sessionName, key]);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Send a key sequence bypassing the _closed check.
|
|
637
|
+
* Used internally during kill() for graceful shutdown (Ctrl+C).
|
|
638
|
+
*/
|
|
639
|
+
private async sendKeyUnsafe(key: string): Promise<void> {
|
|
640
|
+
await this.runTmux(["send-keys", "-t", this.sessionName, key]);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Resize the tmux session window (and pane).
|
|
645
|
+
* Must use resize-window (not resize-pane) because detached sessions
|
|
646
|
+
* constrain pane size to window size, and the window won't grow with resize-pane alone.
|
|
647
|
+
*/
|
|
648
|
+
async resize(cols: number, rows: number): Promise<void> {
|
|
649
|
+
if (this._closed) {
|
|
650
|
+
throw new Error("Cannot resize closed tmux session");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
this.cols = cols;
|
|
654
|
+
this.rows = rows;
|
|
655
|
+
|
|
656
|
+
// Defer resize while in copy-mode — resize-window resets scroll position to 0
|
|
657
|
+
if (this._mode !== "interactive") {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
await this.runTmux([
|
|
662
|
+
"resize-window",
|
|
663
|
+
"-t",
|
|
664
|
+
this.sessionName,
|
|
665
|
+
"-x",
|
|
666
|
+
cols.toString(),
|
|
667
|
+
"-y",
|
|
668
|
+
rows.toString(),
|
|
669
|
+
]);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Kill the tmux session.
|
|
674
|
+
* Flushes pending writes, attempts graceful shutdown, then kills.
|
|
675
|
+
*/
|
|
676
|
+
async kill(signal: "SIGTERM" | "SIGKILL" = "SIGTERM"): Promise<void> {
|
|
677
|
+
if (this._closed) {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Flush pending writes BEFORE stopping anything
|
|
682
|
+
this.flush();
|
|
683
|
+
|
|
684
|
+
// Stop polling to prevent race conditions
|
|
685
|
+
this.stopOutputStreaming();
|
|
686
|
+
|
|
687
|
+
// Clean up monitor interval
|
|
688
|
+
if (this.monitorInterval) {
|
|
689
|
+
clearInterval(this.monitorInterval);
|
|
690
|
+
this.monitorInterval = undefined;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Mark closed once — never reset
|
|
694
|
+
this._closed = true;
|
|
695
|
+
|
|
696
|
+
try {
|
|
697
|
+
if (signal === "SIGTERM") {
|
|
698
|
+
// Send Ctrl+C first for graceful exit
|
|
699
|
+
try {
|
|
700
|
+
await this.sendKeyUnsafe("C-c");
|
|
701
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
702
|
+
} catch (err) {
|
|
703
|
+
console.warn(`Graceful shutdown failed for ${this.sessionName}:`, err);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Kill the tmux session
|
|
708
|
+
const exitCode = await this.killTmuxSession();
|
|
709
|
+
|
|
710
|
+
// Escalate if graceful kill failed
|
|
711
|
+
if (exitCode !== 0 && signal === "SIGTERM") {
|
|
712
|
+
console.warn(`SIGTERM failed for ${this.sessionName}, escalating to SIGKILL`);
|
|
713
|
+
await this.killTmuxSession();
|
|
714
|
+
}
|
|
715
|
+
} catch (err) {
|
|
716
|
+
console.error(`Failed to kill session ${this.sessionName}:`, err);
|
|
717
|
+
throw err;
|
|
718
|
+
} finally {
|
|
719
|
+
this.fireExitCallback(signal === "SIGKILL" ? 137 : 0, signal);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Internal: kill the tmux session and return exit code
|
|
725
|
+
*/
|
|
726
|
+
private async killTmuxSession(): Promise<number> {
|
|
727
|
+
const proc = Bun.spawn(["tmux", "kill-session", "-t", this.sessionName], {
|
|
728
|
+
stdout: "ignore",
|
|
729
|
+
stderr: "pipe",
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
try {
|
|
733
|
+
return await Promise.race([
|
|
734
|
+
proc.exited,
|
|
735
|
+
new Promise<number>((_, reject) =>
|
|
736
|
+
setTimeout(() => reject(new Error("Kill timeout")), 5000)
|
|
737
|
+
),
|
|
738
|
+
]);
|
|
739
|
+
} catch {
|
|
740
|
+
return 1; // Timeout or other error
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Public method to capture visible pane content with ANSI colors.
|
|
746
|
+
* Used by the REST API for initial terminal state fetch.
|
|
747
|
+
*/
|
|
748
|
+
async captureVisiblePane(): Promise<string> {
|
|
749
|
+
return this.capturePaneVisible();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Capture visible pane content with ANSI escape sequences for web streaming.
|
|
754
|
+
* Strips trailing blank lines to avoid massive empty space in web view.
|
|
755
|
+
*
|
|
756
|
+
* In copy-mode (scroll/search), tmux capture-pane without -S/-E returns
|
|
757
|
+
* the LIVE viewport, ignoring scroll position. We must explicitly calculate
|
|
758
|
+
* the capture range from the scroll position to get the scrolled content.
|
|
759
|
+
*/
|
|
760
|
+
private async capturePaneVisible(): Promise<string> {
|
|
761
|
+
if (this._closed) {
|
|
762
|
+
throw new Error("Cannot capture from closed tmux session");
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const raw = await this.runTmux(await this.buildCapturePaneArgs());
|
|
766
|
+
return this.normalizeCapturedContent(raw);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Capture pane content and cursor position in a single tmux invocation.
|
|
771
|
+
* This avoids race conditions where separate capture + cursor reads can
|
|
772
|
+
* observe different UI frames during rapid TUI redraws (for example Codex).
|
|
773
|
+
*/
|
|
774
|
+
private async capturePaneSnapshot(): Promise<PaneSnapshot> {
|
|
775
|
+
if (this._closed) {
|
|
776
|
+
throw new Error("Cannot capture from closed tmux session");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const marker = `__CODEPIPER_CURSOR_MARKER__${this.sessionName}__${this.cursorMarkerSeq++}`;
|
|
780
|
+
const raw = await this.runTmux([
|
|
781
|
+
...(await this.buildCapturePaneArgs()),
|
|
782
|
+
";",
|
|
783
|
+
"display-message",
|
|
784
|
+
"-t",
|
|
785
|
+
this.sessionName,
|
|
786
|
+
"-p",
|
|
787
|
+
`${marker}:#{cursor_x}:#{cursor_y}:#{cursor_flag}`,
|
|
788
|
+
]);
|
|
789
|
+
|
|
790
|
+
let cursor: TerminalCursor | null = null;
|
|
791
|
+
const lines = raw.split("\n");
|
|
792
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
793
|
+
const line = (lines[i] ?? "").replace(/\r$/, "");
|
|
794
|
+
if (!line.startsWith(`${marker}:`)) {
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
const [xRaw, yRaw, visibleRaw] = line.slice(marker.length + 1).split(":");
|
|
798
|
+
const parsedCursor = this.parseCursorParts(xRaw, yRaw, visibleRaw);
|
|
799
|
+
if (!parsedCursor) {
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
cursor = parsedCursor;
|
|
803
|
+
lines.splice(i, 1); // Remove only validated marker metadata line
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
content: this.normalizeCapturedContent(lines.join("\n")),
|
|
809
|
+
cursor,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private normalizeCapturedContent(raw: string): string {
|
|
814
|
+
// Strip trailing blank lines. tmux capture-pane outputs all rows
|
|
815
|
+
// including empty ones, which creates a large gap in the web view.
|
|
816
|
+
const lines = raw.split("\n");
|
|
817
|
+
while (lines.length > 0) {
|
|
818
|
+
const lastLine = lines[lines.length - 1];
|
|
819
|
+
if (lastLine === undefined || lastLine.trim() !== "") {
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
lines.pop();
|
|
823
|
+
}
|
|
824
|
+
return `${lines.join("\n")}\n`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
private async buildCapturePaneArgs(): Promise<string[]> {
|
|
828
|
+
const args = [
|
|
829
|
+
"capture-pane",
|
|
830
|
+
"-t",
|
|
831
|
+
this.sessionName,
|
|
832
|
+
"-p", // Print to stdout
|
|
833
|
+
"-e", // Include escape sequences (colors)
|
|
834
|
+
// Join wrapped lines so historical output isn't "stuck" to an older
|
|
835
|
+
// pane width (for example, after switching between mobile and desktop).
|
|
836
|
+
"-J",
|
|
837
|
+
];
|
|
838
|
+
|
|
839
|
+
// In scroll/search mode, capture the scrolled region instead of live viewport
|
|
840
|
+
if (this._mode !== "interactive") {
|
|
841
|
+
try {
|
|
842
|
+
const info = await this.runTmux([
|
|
843
|
+
"display-message",
|
|
844
|
+
"-t",
|
|
845
|
+
this.sessionName,
|
|
846
|
+
"-p",
|
|
847
|
+
"#{scroll_position}:#{pane_height}",
|
|
848
|
+
]);
|
|
849
|
+
const parts = info.trim().split(":");
|
|
850
|
+
const scrollPos = Number(parts[0] ?? "0");
|
|
851
|
+
const paneHeight = Number(parts[1] ?? "0");
|
|
852
|
+
if (scrollPos > 0 && paneHeight > 0) {
|
|
853
|
+
const startLine = -scrollPos;
|
|
854
|
+
const endLine = startLine + paneHeight - 1;
|
|
855
|
+
args.push("-S", String(startLine), "-E", String(endLine));
|
|
856
|
+
}
|
|
857
|
+
} catch {
|
|
858
|
+
// Fall through to default capture if mode query fails
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return args;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Get current pane content (for debugging/testing)
|
|
867
|
+
* Includes scrollback history for complete output
|
|
868
|
+
*/
|
|
869
|
+
async capturePane(): Promise<string> {
|
|
870
|
+
if (this._closed) {
|
|
871
|
+
throw new Error("Cannot capture from closed tmux session");
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return await this.runTmux([
|
|
875
|
+
"capture-pane",
|
|
876
|
+
"-t",
|
|
877
|
+
this.sessionName,
|
|
878
|
+
"-p", // Print to stdout
|
|
879
|
+
"-S",
|
|
880
|
+
"-", // Start from beginning of scrollback
|
|
881
|
+
"-e", // Include escape sequences (preserves colors)
|
|
882
|
+
"-J", // Join wrapped lines for width-independent replay/debug output
|
|
883
|
+
]);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// --- Terminal mode methods (scroll / search via tmux copy-mode) ---
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Enter scroll mode (tmux copy-mode).
|
|
890
|
+
* In copy-mode, capture-pane automatically reflects the scrolled position.
|
|
891
|
+
*/
|
|
892
|
+
async enterScrollMode(): Promise<void> {
|
|
893
|
+
if (this._closed) throw new Error("Cannot enter scroll mode on closed session");
|
|
894
|
+
if (this._mode !== "interactive") return; // Already in a non-interactive mode
|
|
895
|
+
|
|
896
|
+
// Set mode BEFORE entering copy-mode to block concurrent resize calls.
|
|
897
|
+
// resize-window resets scroll position, so we must block it before copy-mode.
|
|
898
|
+
this._mode = "scroll";
|
|
899
|
+
try {
|
|
900
|
+
await this.runTmux(["copy-mode", "-t", this.sessionName]);
|
|
901
|
+
} catch (e) {
|
|
902
|
+
this._mode = "interactive"; // Revert on failure
|
|
903
|
+
throw e;
|
|
904
|
+
}
|
|
905
|
+
this.onModeChangeCallback?.("scroll");
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Exit scroll/search mode and return to interactive.
|
|
910
|
+
*/
|
|
911
|
+
async exitScrollMode(): Promise<void> {
|
|
912
|
+
if (this._closed) throw new Error("Cannot exit scroll mode on closed session");
|
|
913
|
+
if (this._mode === "interactive") return;
|
|
914
|
+
|
|
915
|
+
await this.runTmux(["send-keys", "-t", this.sessionName, "-X", "cancel"]);
|
|
916
|
+
this._mode = "interactive";
|
|
917
|
+
this.onModeChangeCallback?.("interactive");
|
|
918
|
+
|
|
919
|
+
// Apply any deferred resize (resize-window is skipped during copy-mode).
|
|
920
|
+
// resize() is a no-op when dims haven't changed, otherwise sends resize-window.
|
|
921
|
+
await this.resize(this.cols, this.rows);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Scroll up or down by a number of lines.
|
|
926
|
+
* Auto-enters scroll mode if currently interactive.
|
|
927
|
+
*/
|
|
928
|
+
async scroll(direction: "up" | "down", lines = 1): Promise<void> {
|
|
929
|
+
if (this._closed) throw new Error("Cannot scroll closed session");
|
|
930
|
+
|
|
931
|
+
if (this._mode === "interactive") {
|
|
932
|
+
await this.enterScrollMode();
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const cmd = direction === "up" ? "scroll-up" : "scroll-down";
|
|
936
|
+
await this.runTmux(["send-keys", "-t", this.sessionName, "-X", "-N", String(lines), cmd]);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Scroll up or down by one page.
|
|
941
|
+
* Auto-enters scroll mode if currently interactive.
|
|
942
|
+
*/
|
|
943
|
+
async scrollPage(direction: "up" | "down"): Promise<void> {
|
|
944
|
+
if (this._closed) throw new Error("Cannot scroll closed session");
|
|
945
|
+
|
|
946
|
+
if (this._mode === "interactive") {
|
|
947
|
+
await this.enterScrollMode();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const cmd = direction === "up" ? "page-up" : "page-down";
|
|
951
|
+
await this.runTmux(["send-keys", "-t", this.sessionName, "-X", cmd]);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Scroll to top or bottom of history.
|
|
956
|
+
* Scrolling to bottom also exits scroll mode.
|
|
957
|
+
*/
|
|
958
|
+
async scrollToEdge(edge: "top" | "bottom"): Promise<void> {
|
|
959
|
+
if (this._closed) throw new Error("Cannot scroll closed session");
|
|
960
|
+
|
|
961
|
+
if (edge === "top") {
|
|
962
|
+
if (this._mode === "interactive") {
|
|
963
|
+
await this.enterScrollMode();
|
|
964
|
+
}
|
|
965
|
+
await this.runTmux(["send-keys", "-t", this.sessionName, "-X", "history-top"]);
|
|
966
|
+
} else {
|
|
967
|
+
if (this._mode !== "interactive") {
|
|
968
|
+
await this.exitScrollMode();
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Search backward through terminal history.
|
|
975
|
+
* Auto-enters copy-mode and sets mode to "search".
|
|
976
|
+
*/
|
|
977
|
+
async searchBackward(query: string): Promise<void> {
|
|
978
|
+
if (this._closed) throw new Error("Cannot search closed session");
|
|
979
|
+
if (!query) throw new Error("Search query cannot be empty");
|
|
980
|
+
|
|
981
|
+
if (this._mode === "interactive") {
|
|
982
|
+
await this.runTmux(["copy-mode", "-t", this.sessionName]);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Escape regex metacharacters for tmux search-backward (regex by default)
|
|
986
|
+
const escaped = query.replace(/[-[\]{}()*+?.,\\^$|#]/g, "\\$&");
|
|
987
|
+
await this.runTmux(["send-keys", "-t", this.sessionName, "-X", "search-backward", escaped]);
|
|
988
|
+
this._mode = "search";
|
|
989
|
+
this.onModeChangeCallback?.("search");
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Jump to next search match (forward in time / down).
|
|
994
|
+
*/
|
|
995
|
+
async searchNext(): Promise<void> {
|
|
996
|
+
if (this._closed) throw new Error("Cannot search closed session");
|
|
997
|
+
if (this._mode !== "search") throw new Error("Not in search mode");
|
|
998
|
+
|
|
999
|
+
await this.runTmux(["send-keys", "-t", this.sessionName, "-X", "search-again"]);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Jump to previous search match (backward in time / up).
|
|
1004
|
+
*/
|
|
1005
|
+
async searchPrevious(): Promise<void> {
|
|
1006
|
+
if (this._closed) throw new Error("Cannot search closed session");
|
|
1007
|
+
if (this._mode !== "search") throw new Error("Not in search mode");
|
|
1008
|
+
|
|
1009
|
+
await this.runTmux(["send-keys", "-t", this.sessionName, "-X", "search-reverse"]);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Get terminal info including mode, dimensions, and scroll position.
|
|
1014
|
+
* Syncs internal mode state with tmux reality.
|
|
1015
|
+
*/
|
|
1016
|
+
async getTerminalInfo(): Promise<TerminalInfo> {
|
|
1017
|
+
if (this._closed) throw new Error("Cannot get info from closed session");
|
|
1018
|
+
|
|
1019
|
+
const raw = await this.runTmux([
|
|
1020
|
+
"display-message",
|
|
1021
|
+
"-t",
|
|
1022
|
+
this.sessionName,
|
|
1023
|
+
"-p",
|
|
1024
|
+
"#{pane_in_mode}:#{scroll_position}:#{history_size}:#{pane_width}:#{pane_height}",
|
|
1025
|
+
]);
|
|
1026
|
+
|
|
1027
|
+
const [inMode, scrollPos, histSize, width, height] = raw.trim().split(":");
|
|
1028
|
+
const cols = Number.parseInt(width ?? "", 10);
|
|
1029
|
+
const rows = Number.parseInt(height ?? "", 10);
|
|
1030
|
+
const scrollPosition = Number.parseInt(scrollPos ?? "", 10);
|
|
1031
|
+
const historySize = Number.parseInt(histSize ?? "", 10);
|
|
1032
|
+
|
|
1033
|
+
// Sync mode state: if tmux says we're not in copy-mode but we think we are
|
|
1034
|
+
if (inMode === "0" && this._mode !== "interactive") {
|
|
1035
|
+
this._mode = "interactive";
|
|
1036
|
+
this.onModeChangeCallback?.("interactive");
|
|
1037
|
+
} else if (inMode === "1" && this._mode === "interactive") {
|
|
1038
|
+
this._mode = "scroll";
|
|
1039
|
+
this.onModeChangeCallback?.("scroll");
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
return {
|
|
1043
|
+
mode: this._mode,
|
|
1044
|
+
cols: cols || this.cols,
|
|
1045
|
+
rows: rows || this.rows,
|
|
1046
|
+
scrollPosition: scrollPosition || 0,
|
|
1047
|
+
historySize: historySize || 0,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Run a tmux command, check exit code, return stdout.
|
|
1053
|
+
* Centralizes error handling for all tmux subprocess calls.
|
|
1054
|
+
*/
|
|
1055
|
+
private async runTmux(args: string[]): Promise<string> {
|
|
1056
|
+
const proc = Bun.spawn(["tmux", ...args], {
|
|
1057
|
+
stdout: "pipe",
|
|
1058
|
+
stderr: "pipe",
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
1062
|
+
proc.exited,
|
|
1063
|
+
new Response(proc.stdout).text(),
|
|
1064
|
+
new Response(proc.stderr).text(),
|
|
1065
|
+
]);
|
|
1066
|
+
|
|
1067
|
+
if (exitCode !== 0) {
|
|
1068
|
+
throw new Error(`tmux ${args[0]} failed (exit ${exitCode}): ${stderr.trim()}`);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return stdout;
|
|
1072
|
+
}
|
|
1073
|
+
}
|