pi-better-background-tasks 0.2.4 → 0.2.6

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.
@@ -0,0 +1,902 @@
1
+ // Generated from packages/ssh-core/index.ts. Do not edit directly.
2
+ import type { ChildProcess } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { chmodSync, lstatSync, mkdirSync, rmSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+
8
+ export interface CommandSpec {
9
+ command?: string;
10
+ argv?: string[];
11
+ shell?: boolean;
12
+ cwd?: string;
13
+ env?: Record<string, string>;
14
+ }
15
+
16
+ export interface CommandOutputCapture {
17
+ output: string;
18
+ totalBytes: number;
19
+ totalLines: number;
20
+ fullOutputPath?: string;
21
+ }
22
+
23
+ export interface CommandResult {
24
+ exitCode: number | null;
25
+ signal: NodeJS.Signals | null;
26
+ stdout: string;
27
+ stderr: string;
28
+ startedAt: number;
29
+ endedAt: number;
30
+ timedOut?: boolean;
31
+ outputCapture?: CommandOutputCapture;
32
+ }
33
+
34
+ export interface SpawnedProcess {
35
+ child: ChildProcess;
36
+ pgid?: number;
37
+ }
38
+
39
+ export interface SshConnectionParams {
40
+ host: string;
41
+ user?: string;
42
+ port?: number;
43
+ identity_file?: string;
44
+ jump?: string;
45
+ options?: Record<string, string>;
46
+ }
47
+
48
+ export interface ResolvedSshIdentity {
49
+ host: string;
50
+ user?: string;
51
+ port?: number;
52
+ identityFile?: string;
53
+ jump?: string;
54
+ options?: Record<string, string>;
55
+ target: string;
56
+ }
57
+
58
+ export const DEFAULT_SSH_CONNECT_TIMEOUT_SECONDS = 10;
59
+ export const DEFAULT_SSH_CONTROL_PERSIST_SECONDS = 600;
60
+ export const MAX_SSH_CONTROL_PATH_BYTES = 100;
61
+ export const DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS = 120_000;
62
+
63
+ export interface RemoteRunner {
64
+ spawn(spec: CommandSpec, logPath: string, detached: boolean): SpawnedProcess;
65
+ runOnce(spec: CommandSpec, maxBufferBytes?: number, timeoutMs?: number, signal?: AbortSignal): Promise<CommandResult>;
66
+ }
67
+
68
+ export interface SshCommandInput {
69
+ command?: string;
70
+ cwd?: string;
71
+ env?: Record<string, string>;
72
+ ssh: SshConnectionParams;
73
+ }
74
+
75
+ export interface ResolvedSshCommand {
76
+ commandSpec: CommandSpec;
77
+ identity: ResolvedSshIdentity;
78
+ }
79
+
80
+ export interface RemoteBashCommandInput {
81
+ command: string;
82
+ workdir?: string;
83
+ env?: Record<string, string>;
84
+ preamble?: string;
85
+ }
86
+
87
+ export interface SshMuxControllerOptions extends ResolvedSshCommand {
88
+ runner: RemoteRunner;
89
+ sessionScope: string;
90
+ controlPathRoot?: string;
91
+ }
92
+
93
+ export interface SshMuxStatus {
94
+ state: "up" | "down";
95
+ target: string;
96
+ controlPath: string;
97
+ detail: string;
98
+ commandResult: CommandResult;
99
+ }
100
+
101
+ export interface SshMuxEnsureResult extends SshMuxStatus {
102
+ state: "up";
103
+ reused: boolean;
104
+ }
105
+
106
+ export interface SshMuxCleanupResult {
107
+ state: "stopped" | "not_running";
108
+ target: string;
109
+ controlPath: string;
110
+ detail: string;
111
+ commandResult: CommandResult;
112
+ }
113
+
114
+ export interface SshMuxController {
115
+ readonly controlPath: string;
116
+ ensure(): Promise<SshMuxEnsureResult>;
117
+ status(): Promise<SshMuxStatus>;
118
+ cleanup(): Promise<SshMuxCleanupResult>;
119
+ withMux(commandSpec: CommandSpec): CommandSpec;
120
+ }
121
+
122
+ export class SshMuxError extends Error {
123
+ constructor(message: string, readonly commandResult: CommandResult) {
124
+ super(message);
125
+ this.name = "SshMuxError";
126
+ }
127
+ }
128
+
129
+ export type TmuxPackageManager = "apt-get" | "dnf" | "yum" | "apk" | "pacman" | "zypper" | "brew";
130
+
131
+ type TmuxBootstrapCapability = {
132
+ target: string;
133
+ tmuxPath: string;
134
+ tmuxVersion: string;
135
+ message: string;
136
+ };
137
+
138
+ type TmuxBootstrapGuidance = {
139
+ target: string;
140
+ mutated: boolean;
141
+ verifyCommand: string;
142
+ message: string;
143
+ };
144
+
145
+ export type TmuxBootstrapResult =
146
+ | (TmuxBootstrapCapability & {
147
+ status: "present";
148
+ mutated: false;
149
+ })
150
+ | (TmuxBootstrapCapability & {
151
+ status: "installed";
152
+ packageManager: TmuxPackageManager;
153
+ mutated: true;
154
+ installCommand: string;
155
+ verifyCommand: string;
156
+ })
157
+ | (TmuxBootstrapGuidance & {
158
+ status: "needs_user";
159
+ reason: "passwordless_sudo_unavailable" | "install_disabled";
160
+ packageManager: TmuxPackageManager;
161
+ mutated: false;
162
+ installCommand: string;
163
+ })
164
+ | (TmuxBootstrapGuidance & {
165
+ status: "unknown_package_manager";
166
+ mutated: false;
167
+ })
168
+ | (TmuxBootstrapGuidance & {
169
+ status: "install_failed";
170
+ packageManager?: TmuxPackageManager;
171
+ exitCode: number | null;
172
+ installCommand?: string;
173
+ })
174
+ | (TmuxBootstrapGuidance & {
175
+ status: "timed_out";
176
+ packageManager?: TmuxPackageManager;
177
+ installCommand?: string;
178
+ });
179
+
180
+ export interface TmuxBootstrapOptions {
181
+ timeoutMs?: number;
182
+ }
183
+
184
+ export interface TmuxSessionPollResult {
185
+ status: "running" | "missing" | "timed_out" | number;
186
+ logSize: number;
187
+ output: string;
188
+ commandResult: CommandResult;
189
+ }
190
+
191
+ export interface TmuxSessionControllerOptions extends ResolvedSshCommand {
192
+ runner: RemoteRunner;
193
+ sessionName: string;
194
+ command: string;
195
+ workdir?: string;
196
+ installTmux: boolean;
197
+ }
198
+
199
+ export interface TmuxSessionController {
200
+ bootstrapTmux(options?: TmuxBootstrapOptions): Promise<TmuxBootstrapResult>;
201
+ startTmuxSession(tmuxPath: string): Promise<CommandResult>;
202
+ pollTmuxSession(logOffset: number, timeoutMs?: number): Promise<TmuxSessionPollResult>;
203
+ killTmuxSession(): Promise<CommandResult>;
204
+ }
205
+
206
+ const REQUIRED_SSH_OPTIONS = new Set(["batchmode", "connecttimeout", "requesttty"]);
207
+ const MUX_CONTROL_OPTIONS = new Set(["controlmaster", "controlpath", "controlpersist"]);
208
+ const TMUX_STATUS_PREFIX = "__PI_BG_STATUS__=";
209
+ const TMUX_SIZE_PREFIX = "__PI_BG_SIZE__=";
210
+ const TMUX_PATH_PREFIX = "__PI_BG_TMUX_PATH__=";
211
+ const TMUX_VERSION_PREFIX = "__PI_BG_TMUX_VERSION__=";
212
+ const TMUX_CAPTURE_CHUNK_BYTES = 256 * 1024;
213
+ const TMUX_PROBE_COMMAND = [
214
+ "tmux_path=$(command -v tmux) || exit 127",
215
+ `printf '${TMUX_PATH_PREFIX}%s\\n' "$tmux_path"`,
216
+ `printf '${TMUX_VERSION_PREFIX}%s\\n' "$("$tmux_path" -V)"`,
217
+ ].join("; ");
218
+ const TMUX_PACKAGE_MANAGERS: TmuxPackageManager[] = ["apt-get", "dnf", "yum", "apk", "pacman", "zypper", "brew"];
219
+ const TMUX_INSTALL_COMMANDS: Record<TmuxPackageManager, string> = {
220
+ "apt-get": "apt-get update && apt-get install -y tmux",
221
+ dnf: "dnf install -y tmux",
222
+ yum: "yum install -y tmux",
223
+ apk: "apk add --no-cache tmux",
224
+ pacman: "pacman -Sy --noconfirm tmux",
225
+ zypper: "zypper --non-interactive install tmux",
226
+ brew: "brew install tmux",
227
+ };
228
+ const TMUX_DETECT_COMMAND = [
229
+ "remote_user=$(id -un) || exit 1",
230
+ "remote_uid=$(id -u) || exit 1",
231
+ "package_manager=''",
232
+ `for candidate in ${TMUX_PACKAGE_MANAGERS.join(" ")}; do if command -v \"$candidate\" >/dev/null 2>&1; then package_manager=$candidate; break; fi; done`,
233
+ "if [ \"$package_manager\" = brew ]; then privilege=direct; elif [ \"$remote_uid\" -eq 0 ]; then privilege=root; elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then privilege=sudo; else privilege=needs_user; fi",
234
+ "printf 'user=%s\\nuid=%s\\npm=%s\\nprivilege=%s\\n' \"$remote_user\" \"$remote_uid\" \"$package_manager\" \"$privilege\"",
235
+ ].join("; ");
236
+
237
+ export function resolveSshCommand(input: SshCommandInput): ResolvedSshCommand {
238
+ const command = requireValue(input.command, "command is required when ssh is set");
239
+ const host = requireToken(input.ssh.host, "ssh.host is required");
240
+ const user = optionalToken(input.ssh.user, "ssh.user must not be empty or contain whitespace");
241
+ const target = user ? `${user}@${host}` : host;
242
+ const argv = [
243
+ "ssh",
244
+ "-o", "BatchMode=yes",
245
+ "-o", `ConnectTimeout=${DEFAULT_SSH_CONNECT_TIMEOUT_SECONDS}`,
246
+ "-T",
247
+ ];
248
+
249
+ if (input.ssh.port !== undefined) {
250
+ if (!Number.isInteger(input.ssh.port) || input.ssh.port < 1 || input.ssh.port > 65_535) {
251
+ throw new Error("ssh.port must be an integer between 1 and 65535");
252
+ }
253
+ argv.push("-p", String(input.ssh.port));
254
+ }
255
+
256
+ const identityFile = optionalValue(input.ssh.identity_file, "ssh.identity_file must not be empty");
257
+ if (identityFile) argv.push("-i", identityFile);
258
+ const jump = optionalValue(input.ssh.jump, "ssh.jump must not be empty");
259
+ if (jump) argv.push("-J", jump);
260
+
261
+ for (const [rawKey, rawValue] of Object.entries(input.ssh.options ?? {})) {
262
+ const key = requireToken(rawKey, "ssh option names must not be empty or contain whitespace");
263
+ if (REQUIRED_SSH_OPTIONS.has(key.toLowerCase())) continue;
264
+ argv.push("-o", `${key}=${String(rawValue)}`);
265
+ }
266
+ argv.push("--", target, command);
267
+
268
+ return {
269
+ commandSpec: {
270
+ command,
271
+ argv,
272
+ shell: false,
273
+ cwd: input.cwd,
274
+ env: input.env,
275
+ },
276
+ identity: {
277
+ host,
278
+ ...(user ? { user } : {}),
279
+ ...(input.ssh.port !== undefined ? { port: input.ssh.port } : {}),
280
+ ...(identityFile ? { identityFile } : {}),
281
+ ...(jump ? { jump } : {}),
282
+ ...(input.ssh.options ? { options: { ...input.ssh.options } } : {}),
283
+ target,
284
+ },
285
+ };
286
+ }
287
+
288
+ export function wrapRemoteBashCommand(input: RemoteBashCommandInput): string {
289
+ const command = requireValue(input.command, "remote bash command is required");
290
+ const workdir = input.workdir === undefined
291
+ ? undefined
292
+ : requireValue(input.workdir, "remote bash workdir must not be empty");
293
+ const preamble = input.preamble === undefined
294
+ ? undefined
295
+ : requireValue(input.preamble, "remote bash preamble must not be empty");
296
+ const script = [
297
+ ...(workdir ? [`cd -- ${shellQuote(workdir)} || exit $?`] : []),
298
+ ...Object.entries(input.env ?? {})
299
+ .sort(([left], [right]) => left.localeCompare(right))
300
+ .map(([name, value]) => {
301
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
302
+ throw new Error(`invalid remote environment variable name: ${JSON.stringify(name)}`);
303
+ }
304
+ return `export ${name}=${shellQuote(String(value))}`;
305
+ }),
306
+ ...(preamble ? [preamble] : []),
307
+ command,
308
+ ].join("\n");
309
+ return `bash -c ${shellQuote(script)}`;
310
+ }
311
+
312
+ export function defaultSshControlPathRoot(): string {
313
+ const agentDirectory = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
314
+ return join(agentDirectory, "ssh-control");
315
+ }
316
+
317
+ /**
318
+ * Manages connection reuse for short synchronous SSH commands only.
319
+ * Durable remote jobs must remain correct when no ControlMaster exists.
320
+ */
321
+ export function createSshMuxController(options: SshMuxControllerOptions): SshMuxController {
322
+ const sessionScope = requireValue(options.sessionScope, "ssh mux sessionScope is required").trim();
323
+ const controlPathRoot = resolve(options.controlPathRoot ?? defaultSshControlPathRoot());
324
+ const controlPath = join(controlPathRoot, `cm-${muxIdentityHash(options.identity, sessionScope)}`);
325
+ if (Buffer.byteLength(controlPath) > MAX_SSH_CONTROL_PATH_BYTES) {
326
+ throw new Error(`ssh mux ControlPath exceeds ${MAX_SSH_CONTROL_PATH_BYTES} bytes; configure a shorter controlPathRoot`);
327
+ }
328
+ const target = options.identity.target;
329
+
330
+ const prepareRoot = (): void => ensureControlPathRoot(controlPathRoot);
331
+ const buildControlCommand = (operation: "check" | "exit"): CommandSpec => {
332
+ prepareRoot();
333
+ return muxCommandSpec(options.commandSpec, target, [
334
+ "-o", "ControlMaster=no",
335
+ "-o", `ControlPath=${controlPath}`,
336
+ "-O", operation,
337
+ ], false);
338
+ };
339
+ const status = async (): Promise<SshMuxStatus> => {
340
+ const commandResult = await options.runner.runOnce(buildControlCommand("check"));
341
+ return {
342
+ state: commandSucceeded(commandResult) ? "up" : "down",
343
+ target,
344
+ controlPath,
345
+ detail: resultDetail(commandResult) || (commandSucceeded(commandResult) ? "ControlMaster is running." : "ControlMaster is not running."),
346
+ commandResult,
347
+ };
348
+ };
349
+
350
+ return {
351
+ controlPath,
352
+ status,
353
+ withMux: (commandSpec) => {
354
+ prepareRoot();
355
+ return muxCommandSpec(commandSpec, target, [
356
+ "-o", "ControlMaster=no",
357
+ "-o", `ControlPath=${controlPath}`,
358
+ ], true);
359
+ },
360
+ ensure: async () => {
361
+ const existing = await status();
362
+ if (existing.state === "up") return { ...existing, state: "up", reused: true };
363
+
364
+ rmSync(controlPath, { force: true });
365
+ const opened = await options.runner.runOnce(muxCommandSpec(options.commandSpec, target, [
366
+ "-o", "ControlMaster=yes",
367
+ "-o", `ControlPersist=${DEFAULT_SSH_CONTROL_PERSIST_SECONDS}`,
368
+ "-o", `ControlPath=${controlPath}`,
369
+ "-N", "-f",
370
+ ], false));
371
+ if (!commandSucceeded(opened)) {
372
+ throw new SshMuxError(
373
+ `SSH ControlMaster for ${target} failed to establish after one reopen attempt${resultDetail(opened) ? `: ${resultDetail(opened)}` : "."}`,
374
+ opened,
375
+ );
376
+ }
377
+
378
+ const reopened = await status();
379
+ if (reopened.state !== "up") {
380
+ throw new SshMuxError(
381
+ `SSH ControlMaster for ${target} failed to establish after one reopen attempt${reopened.detail ? `: ${reopened.detail}` : "."}`,
382
+ reopened.commandResult,
383
+ );
384
+ }
385
+ return { ...reopened, state: "up", reused: false };
386
+ },
387
+ cleanup: async () => {
388
+ let commandResult: CommandResult;
389
+ try {
390
+ commandResult = await options.runner.runOnce(buildControlCommand("exit"));
391
+ } finally {
392
+ rmSync(controlPath, { force: true });
393
+ }
394
+ return {
395
+ state: commandSucceeded(commandResult) ? "stopped" : "not_running",
396
+ target,
397
+ controlPath,
398
+ detail: resultDetail(commandResult) || (commandSucceeded(commandResult) ? "ControlMaster stopped." : "ControlMaster was not running."),
399
+ commandResult,
400
+ };
401
+ },
402
+ };
403
+ }
404
+
405
+ export function createTmuxSessionController(options: TmuxSessionControllerOptions): TmuxSessionController {
406
+ return {
407
+ bootstrapTmux: (bootstrapOptions) => bootstrapTmux(
408
+ options.commandSpec,
409
+ options.identity.target,
410
+ options.runner,
411
+ options.installTmux,
412
+ resolveBootstrapTimeoutMs(bootstrapOptions?.timeoutMs),
413
+ ),
414
+ startTmuxSession: (tmuxPath) => options.runner.runOnce(withRemoteCommand(
415
+ options.commandSpec,
416
+ tmuxStartCommand(tmuxPath, options.sessionName, options.command, options.workdir),
417
+ )),
418
+ pollTmuxSession: async (logOffset, timeoutMs) => parseTmuxPollResult(await options.runner.runOnce(withRemoteCommand(
419
+ options.commandSpec,
420
+ tmuxPollCommand(options.sessionName, logOffset),
421
+ ), undefined, timeoutMs), logOffset),
422
+ killTmuxSession: () => options.runner.runOnce(withRemoteCommand(
423
+ options.commandSpec,
424
+ `tmux kill-session -t ${shellQuote(options.sessionName)}`,
425
+ )),
426
+ };
427
+ }
428
+
429
+ function muxIdentityHash(identity: ResolvedSshIdentity, sessionScope: string): string {
430
+ const optionValues = new Map<string, string>();
431
+ for (const [rawKey, rawValue] of Object.entries(identity.options ?? {})) {
432
+ const key = rawKey.toLowerCase();
433
+ if (REQUIRED_SSH_OPTIONS.has(key) || MUX_CONTROL_OPTIONS.has(key) || optionValues.has(key)) continue;
434
+ optionValues.set(key, String(rawValue));
435
+ }
436
+ const effectiveOptions = [...optionValues].sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey));
437
+ const connectionIdentity = JSON.stringify({
438
+ version: 1,
439
+ sessionScope,
440
+ target: identity.target,
441
+ port: identity.port ?? null,
442
+ identityFile: identity.identityFile ?? null,
443
+ jump: identity.jump ?? null,
444
+ options: effectiveOptions,
445
+ });
446
+ return createHash("sha256").update(connectionIdentity).digest("hex").slice(0, 32);
447
+ }
448
+
449
+ function ensureControlPathRoot(controlPathRoot: string): void {
450
+ mkdirSync(controlPathRoot, { recursive: true, mode: 0o700 });
451
+ const stats = lstatSync(controlPathRoot);
452
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
453
+ throw new Error(`ssh mux ControlPath root must be a real directory: ${controlPathRoot}`);
454
+ }
455
+ chmodSync(controlPathRoot, 0o700);
456
+ }
457
+
458
+ function muxCommandSpec(
459
+ commandSpec: CommandSpec,
460
+ target: string,
461
+ muxArgv: string[],
462
+ preserveRemoteCommand: boolean,
463
+ ): CommandSpec {
464
+ const argv = commandSpec.argv ?? [];
465
+ const separator = argv.lastIndexOf("--");
466
+ if (argv[0] !== "ssh" || separator < 1 || argv[separator + 1] !== target) {
467
+ throw new Error("ssh mux requires a command produced by resolveSshCommand");
468
+ }
469
+ const connectionArgv = withoutCallerMuxOptions(argv.slice(0, separator));
470
+ const destinationArgv = preserveRemoteCommand ? argv.slice(separator + 1) : [target];
471
+ return {
472
+ ...(preserveRemoteCommand && commandSpec.command !== undefined ? { command: commandSpec.command } : {}),
473
+ argv: [...connectionArgv, ...muxArgv, "--", ...destinationArgv],
474
+ shell: false,
475
+ ...(commandSpec.cwd !== undefined ? { cwd: commandSpec.cwd } : {}),
476
+ ...(commandSpec.env !== undefined ? { env: commandSpec.env } : {}),
477
+ };
478
+ }
479
+
480
+ function withoutCallerMuxOptions(connectionArgv: string[]): string[] {
481
+ const sanitized: string[] = [];
482
+ for (let index = 0; index < connectionArgv.length; index += 1) {
483
+ const argument = connectionArgv[index]!;
484
+ const value = connectionArgv[index + 1];
485
+ if (argument === "-o" && value) {
486
+ const separator = value.indexOf("=");
487
+ const key = (separator < 0 ? value : value.slice(0, separator)).toLowerCase();
488
+ if (MUX_CONTROL_OPTIONS.has(key)) {
489
+ index += 1;
490
+ continue;
491
+ }
492
+ }
493
+ sanitized.push(argument);
494
+ }
495
+ return sanitized;
496
+ }
497
+
498
+ function commandSucceeded(result: CommandResult): boolean {
499
+ return result.exitCode === 0 && !result.timedOut;
500
+ }
501
+
502
+ function tmuxLogPath(sessionName: string): string {
503
+ return `/tmp/${sessionName}.log`;
504
+ }
505
+
506
+ function tmuxExitPath(sessionName: string): string {
507
+ return `/tmp/${sessionName}.exit`;
508
+ }
509
+
510
+ function tmuxStartCommand(tmuxPath: string, sessionName: string, command: string, workdir?: string): string {
511
+ const logPath = tmuxLogPath(sessionName);
512
+ const exitPath = tmuxExitPath(sessionName);
513
+ const script = [
514
+ ...(workdir ? [`cd -- ${shellQuote(workdir)}`] : []),
515
+ `sh -lc ${shellQuote(command)} >${shellQuote(logPath)} 2>&1`,
516
+ "exit_code=$?",
517
+ `printf '%s\\n' "$exit_code" >${shellQuote(exitPath)}`,
518
+ "exit \"$exit_code\"",
519
+ ].join("; ");
520
+ return [
521
+ `rm -f ${shellQuote(logPath)} ${shellQuote(exitPath)}`,
522
+ `${shellQuote(tmuxPath)} new-session -d -s ${shellQuote(sessionName)} sh -lc ${shellQuote(script)}`,
523
+ ].join("; ");
524
+ }
525
+
526
+ function tmuxPollCommand(sessionName: string, logOffset: number): string {
527
+ const normalizedOffset = Math.max(0, Math.floor(logOffset));
528
+ const logPath = tmuxLogPath(sessionName);
529
+ const exitPath = tmuxExitPath(sessionName);
530
+ return [
531
+ "status=running",
532
+ `if test -f ${shellQuote(exitPath)}; then status=$(cat ${shellQuote(exitPath)}); elif ! tmux has-session -t ${shellQuote(sessionName)} 2>/dev/null; then status=missing; fi`,
533
+ `size=$(wc -c < ${shellQuote(logPath)} 2>/dev/null || printf '0')`,
534
+ "size=$(printf '%s' \"$size\" | tr -d '[:space:]')",
535
+ `next_offset=$(( ${normalizedOffset} + ${TMUX_CAPTURE_CHUNK_BYTES} ))`,
536
+ "if test \"$next_offset\" -gt \"$size\"; then next_offset=$size; fi",
537
+ "reported_status=$status",
538
+ "if test \"$next_offset\" -lt \"$size\"; then reported_status=running; fi",
539
+ `printf '${TMUX_STATUS_PREFIX}%s\\n${TMUX_SIZE_PREFIX}%s\\n' "$reported_status" "$next_offset"`,
540
+ `if test "$next_offset" -gt ${normalizedOffset}; then tail -c +${normalizedOffset + 1} ${shellQuote(logPath)} | head -c $(( next_offset - ${normalizedOffset} )); fi`,
541
+ ].join("; ");
542
+ }
543
+
544
+ function parseTmuxPollResult(result: CommandResult, logOffset: number): TmuxSessionPollResult {
545
+ if (result.timedOut) {
546
+ return {
547
+ status: "timed_out",
548
+ logSize: Math.max(0, Math.floor(logOffset)),
549
+ output: result.stdout,
550
+ commandResult: result,
551
+ };
552
+ }
553
+ const [statusLine = "", sizeLine = "", ...outputLines] = result.stdout.split("\n");
554
+ if (!statusLine.startsWith(TMUX_STATUS_PREFIX) || !sizeLine.startsWith(TMUX_SIZE_PREFIX)) {
555
+ throw new Error(`remote tmux supervision returned an invalid response${result.stderr.trim() ? `: ${result.stderr.trim()}` : ""}`);
556
+ }
557
+ const rawStatus = statusLine.slice(TMUX_STATUS_PREFIX.length);
558
+ const rawSize = sizeLine.slice(TMUX_SIZE_PREFIX.length);
559
+ const logSize = Number(rawSize);
560
+ if (!Number.isSafeInteger(logSize) || logSize < 0) {
561
+ throw new Error(`remote tmux supervision returned invalid log size ${JSON.stringify(rawSize)}`);
562
+ }
563
+ const status = rawStatus === "running" || rawStatus === "missing"
564
+ ? rawStatus
565
+ : Number(rawStatus);
566
+ if (typeof status === "number" && !Number.isInteger(status)) {
567
+ throw new Error(`remote tmux supervision returned invalid status ${JSON.stringify(rawStatus)}`);
568
+ }
569
+ return {
570
+ status,
571
+ logSize,
572
+ output: outputLines.join("\n"),
573
+ commandResult: result,
574
+ };
575
+ }
576
+
577
+ async function bootstrapTmux(
578
+ commandSpec: CommandSpec,
579
+ target: string,
580
+ runner: RemoteRunner,
581
+ installEnabled: boolean,
582
+ timeoutMs: number,
583
+ ): Promise<TmuxBootstrapResult> {
584
+ const deadlineAt = Date.now() + timeoutMs;
585
+ let timeoutContext: TmuxBootstrapTimeoutContext = {
586
+ target,
587
+ mutated: false,
588
+ verifyCommand: sshGuidanceCommand(commandSpec, target, "command -v tmux && tmux -V"),
589
+ stage: "probing tmux",
590
+ };
591
+ const run = async (remoteCommand: string): Promise<CommandResult> => {
592
+ const remainingMs = deadlineAt - Date.now();
593
+ if (remainingMs <= 0) throw new TmuxBootstrapTimeoutError();
594
+ const result = await runner.runOnce(withRemoteCommand(commandSpec, remoteCommand), undefined, remainingMs);
595
+ if (result.timedOut) throw new TmuxBootstrapTimeoutError();
596
+ return result;
597
+ };
598
+
599
+ try {
600
+ return await bootstrapTmuxWithinDeadline(run, commandSpec, target, installEnabled, timeoutContext, (context) => {
601
+ timeoutContext = context;
602
+ });
603
+ } catch (error) {
604
+ if (!(error instanceof TmuxBootstrapTimeoutError)) throw error;
605
+ const remediation = timeoutContext.installCommand
606
+ ? `Run: ${timeoutContext.installCommand} Then verify: ${timeoutContext.verifyCommand}`
607
+ : `Install tmux manually if needed, then verify: ${timeoutContext.verifyCommand}`;
608
+ return {
609
+ status: "timed_out",
610
+ target: timeoutContext.target,
611
+ ...(timeoutContext.packageManager ? { packageManager: timeoutContext.packageManager } : {}),
612
+ mutated: timeoutContext.mutated,
613
+ ...(timeoutContext.installCommand ? { installCommand: timeoutContext.installCommand } : {}),
614
+ verifyCommand: timeoutContext.verifyCommand,
615
+ message: `tmux bootstrap timed out on ${timeoutContext.target} while ${timeoutContext.stage}. ${remediation}`,
616
+ };
617
+ }
618
+ }
619
+
620
+ interface TmuxBootstrapTimeoutContext {
621
+ target: string;
622
+ packageManager?: TmuxPackageManager;
623
+ mutated: boolean;
624
+ installCommand?: string;
625
+ verifyCommand: string;
626
+ stage: string;
627
+ }
628
+
629
+ class TmuxBootstrapTimeoutError extends Error {}
630
+
631
+ async function bootstrapTmuxWithinDeadline(
632
+ run: (remoteCommand: string) => Promise<CommandResult>,
633
+ commandSpec: CommandSpec,
634
+ target: string,
635
+ installEnabled: boolean,
636
+ initialTimeoutContext: TmuxBootstrapTimeoutContext,
637
+ setTimeoutContext: (context: TmuxBootstrapTimeoutContext) => void,
638
+ ): Promise<TmuxBootstrapResult> {
639
+ setTimeoutContext(initialTimeoutContext);
640
+ const probe = await run(TMUX_PROBE_COMMAND);
641
+ const capability = parseTmuxCapability(probe);
642
+ if (capability) {
643
+ return {
644
+ status: "present",
645
+ target,
646
+ ...capability,
647
+ mutated: false,
648
+ message: `${capability.tmuxVersion} is available at ${capability.tmuxPath} on ${target}.`,
649
+ };
650
+ }
651
+ const initialVerifyCommand = sshGuidanceCommand(commandSpec, target, "command -v tmux && tmux -V");
652
+ if (probe.exitCode !== 127) {
653
+ const detail = resultDetail(probe);
654
+ return {
655
+ status: "install_failed",
656
+ target,
657
+ exitCode: probe.exitCode,
658
+ mutated: false,
659
+ verifyCommand: initialVerifyCommand,
660
+ message: `tmux bootstrap could not probe ${target} (exit ${probe.exitCode ?? "unknown"})${detail ? `: ${detail}` : "."} Install tmux manually if needed, then verify: ${initialVerifyCommand}`,
661
+ };
662
+ }
663
+
664
+ setTimeoutContext({ ...initialTimeoutContext, stage: "detecting the package manager" });
665
+ const detection = await run(TMUX_DETECT_COMMAND);
666
+ if (detection.exitCode !== 0) {
667
+ const detail = resultDetail(detection);
668
+ return {
669
+ status: "install_failed",
670
+ target,
671
+ exitCode: detection.exitCode,
672
+ mutated: false,
673
+ verifyCommand: initialVerifyCommand,
674
+ message: `tmux bootstrap could not inspect ${target} for an installer (exit ${detection.exitCode ?? "unknown"})${detail ? `: ${detail}` : "."} Install tmux manually if needed, then verify: ${initialVerifyCommand}`,
675
+ };
676
+ }
677
+ const detected = parseDetection(detection);
678
+ const guidanceTarget = detected && !target.includes("@") ? `${detected.user}@${target}` : target;
679
+ const verifyCommand = sshGuidanceCommand(commandSpec, guidanceTarget, "command -v tmux && tmux -V");
680
+ if (!detected || !isTmuxPackageManager(detected.packageManager)) {
681
+ return {
682
+ status: "unknown_package_manager",
683
+ target: guidanceTarget,
684
+ mutated: false,
685
+ verifyCommand,
686
+ message: `tmux is missing on ${guidanceTarget}, but none of ${formatPackageManagerList()} was found. Install tmux manually, then verify: ${verifyCommand}`,
687
+ };
688
+ }
689
+
690
+ const packageManager = detected.packageManager;
691
+ const baseInstallCommand = TMUX_INSTALL_COMMANDS[packageManager];
692
+ const needsSudo = detected.privilege === "sudo" || detected.privilege === "needs_user";
693
+ const humanInstallCommand = needsSudo ? withInteractiveSudo(baseInstallCommand) : baseInstallCommand;
694
+ const installCommand = sshGuidanceCommand(commandSpec, guidanceTarget, humanInstallCommand, needsSudo);
695
+ if (!installEnabled) {
696
+ return {
697
+ status: "needs_user",
698
+ reason: "install_disabled",
699
+ target: guidanceTarget,
700
+ packageManager,
701
+ mutated: false,
702
+ installCommand,
703
+ verifyCommand,
704
+ message: `Automatic tmux installation is disabled for ${guidanceTarget}. Run: ${installCommand} Then verify: ${verifyCommand}`,
705
+ };
706
+ }
707
+ if (detected.privilege === "needs_user") {
708
+ return {
709
+ status: "needs_user",
710
+ reason: "passwordless_sudo_unavailable",
711
+ target: guidanceTarget,
712
+ packageManager,
713
+ mutated: false,
714
+ installCommand,
715
+ verifyCommand,
716
+ message: `tmux is missing on ${guidanceTarget} and automatic installation cannot use passwordless sudo. Run: ${installCommand} Then verify: ${verifyCommand}`,
717
+ };
718
+ }
719
+
720
+ const machineInstallCommand = detected.privilege === "sudo"
721
+ ? withNonInteractiveSudo(baseInstallCommand)
722
+ : baseInstallCommand;
723
+ setTimeoutContext({
724
+ target: guidanceTarget,
725
+ packageManager,
726
+ mutated: false,
727
+ installCommand,
728
+ verifyCommand,
729
+ stage: `running the ${packageManager} install`,
730
+ });
731
+ const install = await run(machineInstallCommand);
732
+ if (install.exitCode !== 0) {
733
+ const detail = resultDetail(install);
734
+ return {
735
+ status: "install_failed",
736
+ target: guidanceTarget,
737
+ packageManager,
738
+ exitCode: install.exitCode,
739
+ mutated: false,
740
+ installCommand,
741
+ verifyCommand,
742
+ message: `Automatic tmux install with ${packageManager} failed on ${guidanceTarget} (exit ${install.exitCode ?? "unknown"})${detail ? `: ${detail}` : "."} Run: ${installCommand} Then verify: ${verifyCommand}`,
743
+ };
744
+ }
745
+
746
+ setTimeoutContext({
747
+ target: guidanceTarget,
748
+ packageManager,
749
+ mutated: true,
750
+ installCommand,
751
+ verifyCommand,
752
+ stage: "verifying the installed tmux",
753
+ });
754
+ const reprobe = await run(TMUX_PROBE_COMMAND);
755
+ const installedCapability = parseTmuxCapability(reprobe);
756
+ if (!installedCapability) {
757
+ return {
758
+ status: "install_failed",
759
+ target: guidanceTarget,
760
+ packageManager,
761
+ exitCode: reprobe.exitCode,
762
+ mutated: true,
763
+ installCommand,
764
+ verifyCommand,
765
+ message: `The ${packageManager} install command completed but tmux did not pass verification on ${guidanceTarget}${resultDetail(reprobe) ? `: ${resultDetail(reprobe)}` : "."} Run: ${installCommand} Then verify: ${verifyCommand}`,
766
+ };
767
+ }
768
+ return {
769
+ status: "installed",
770
+ target: guidanceTarget,
771
+ packageManager,
772
+ ...installedCapability,
773
+ mutated: true,
774
+ installCommand,
775
+ verifyCommand,
776
+ message: `Installed tmux with ${packageManager} on ${guidanceTarget}; ${installedCapability.tmuxVersion} is available at ${installedCapability.tmuxPath}.`,
777
+ };
778
+ }
779
+
780
+ function resolveBootstrapTimeoutMs(timeoutMs: number | undefined): number {
781
+ if (timeoutMs === undefined) return DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS;
782
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
783
+ throw new Error("tmux bootstrap timeoutMs must be a positive finite number");
784
+ }
785
+ return Math.floor(timeoutMs);
786
+ }
787
+
788
+ function parseTmuxCapability(result: CommandResult): { tmuxPath: string; tmuxVersion: string } | undefined {
789
+ if (result.exitCode !== 0) return undefined;
790
+ let tmuxPath: string | undefined;
791
+ let tmuxVersion: string | undefined;
792
+ for (const line of result.stdout.split("\n")) {
793
+ if (line.startsWith(TMUX_PATH_PREFIX)) tmuxPath = line.slice(TMUX_PATH_PREFIX.length);
794
+ if (line.startsWith(TMUX_VERSION_PREFIX)) tmuxVersion = line.slice(TMUX_VERSION_PREFIX.length);
795
+ }
796
+ if (!tmuxPath || !tmuxVersion) return undefined;
797
+ if (!tmuxPath.startsWith("/") || /\s/.test(tmuxPath)) return undefined;
798
+ if (!/^tmux\b/i.test(tmuxVersion)) return undefined;
799
+ return { tmuxPath, tmuxVersion };
800
+ }
801
+
802
+ interface TmuxDetection {
803
+ user: string;
804
+ packageManager: string;
805
+ privilege: "root" | "sudo" | "direct" | "needs_user";
806
+ }
807
+
808
+ function parseDetection(result: CommandResult): TmuxDetection | undefined {
809
+ if (result.exitCode !== 0) return undefined;
810
+ const values = Object.fromEntries(result.stdout.trim().split("\n").map((line) => {
811
+ const separator = line.indexOf("=");
812
+ return separator < 0 ? [line, ""] : [line.slice(0, separator), line.slice(separator + 1)];
813
+ }));
814
+ if (!values.user || !/^(root|sudo|direct|needs_user)$/.test(values.privilege ?? "")) return undefined;
815
+ return {
816
+ user: values.user,
817
+ packageManager: values.pm ?? "",
818
+ privilege: values.privilege as TmuxDetection["privilege"],
819
+ };
820
+ }
821
+
822
+ function isTmuxPackageManager(value: string | undefined): value is TmuxPackageManager {
823
+ return TMUX_PACKAGE_MANAGERS.some((candidate) => candidate === value);
824
+ }
825
+
826
+ function formatPackageManagerList(): string {
827
+ return `${TMUX_PACKAGE_MANAGERS.slice(0, -1).join(", ")}, or ${TMUX_PACKAGE_MANAGERS.at(-1)}`;
828
+ }
829
+
830
+ function resultDetail(result: CommandResult): string {
831
+ return (result.stderr.trim() || result.stdout.trim()).replaceAll(/\s+/g, " ").slice(0, 500);
832
+ }
833
+
834
+ function withNonInteractiveSudo(command: string): string {
835
+ return command.split(" && ").map((part) => `sudo -n ${part}`).join(" && ");
836
+ }
837
+
838
+ function withInteractiveSudo(command: string): string {
839
+ return command.split(" && ").map((part) => `sudo ${part}`).join(" && ");
840
+ }
841
+
842
+ function sshGuidanceCommand(
843
+ spec: CommandSpec,
844
+ target: string,
845
+ remoteCommand: string,
846
+ tty = false,
847
+ ): string {
848
+ const argv = spec.argv ?? [];
849
+ const separator = argv.lastIndexOf("--");
850
+ const connectionArgs: string[] = [];
851
+ for (let index = 1; index >= 0 && index < separator; index += 1) {
852
+ const option = argv[index];
853
+ if (option === "-T") continue;
854
+ const value = argv[index + 1];
855
+ if ((option === "-p" || option === "-i" || option === "-J") && value) {
856
+ connectionArgs.push(option, shellQuote(value));
857
+ index += 1;
858
+ continue;
859
+ }
860
+ if (option === "-o" && value) {
861
+ index += 1;
862
+ const key = value.slice(0, value.indexOf("=")).toLowerCase();
863
+ if (REQUIRED_SSH_OPTIONS.has(key)) continue;
864
+ connectionArgs.push(option, shellQuote(value));
865
+ }
866
+ }
867
+ const renderedArgs = connectionArgs.length > 0 ? ` ${connectionArgs.join(" ")}` : "";
868
+ return `ssh${tty ? " -t" : ""}${renderedArgs} ${shellQuote(target)} ${shellQuote(remoteCommand)}`;
869
+ }
870
+
871
+ function shellQuote(value: string): string {
872
+ return `'${value.replaceAll("'", `'\"'\"'`)}'`;
873
+ }
874
+
875
+ function withRemoteCommand(spec: CommandSpec, remoteCommand: string): CommandSpec {
876
+ return {
877
+ ...spec,
878
+ command: remoteCommand,
879
+ argv: [...(spec.argv?.slice(0, -1) ?? []), remoteCommand],
880
+ };
881
+ }
882
+
883
+ function requireValue(value: string | undefined, message: string): string {
884
+ if (!value || value.trim().length === 0) throw new Error(message);
885
+ return value;
886
+ }
887
+
888
+ function requireToken(value: string | undefined, message: string): string {
889
+ const resolved = requireValue(value, message).trim();
890
+ if (/\s/.test(resolved)) throw new Error(message);
891
+ return resolved;
892
+ }
893
+
894
+ function optionalValue(value: string | undefined, message: string): string | undefined {
895
+ if (value === undefined) return undefined;
896
+ return requireValue(value, message);
897
+ }
898
+
899
+ function optionalToken(value: string | undefined, message: string): string | undefined {
900
+ if (value === undefined) return undefined;
901
+ return requireToken(value, message);
902
+ }