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.
Files changed (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. 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
+ }