pi-better-harness 0.3.2 → 0.3.4

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,314 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ DEFAULT_MAX_BYTES,
4
+ DEFAULT_MAX_LINES,
5
+ formatSize,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { Type } from "typebox";
8
+ import { createSshMuxRegistry } from "./mux-registry.js";
9
+ import type { SshMuxEntryMap, SshMuxRegistry } from "./mux-registry.js";
10
+ import { createProcessRemoteRunner } from "./process-runner.js";
11
+ import {
12
+ createSshProfile,
13
+ formatSshProfileChip,
14
+ listSshHostAliases,
15
+ profileStateEntry,
16
+ restoreSshProfile,
17
+ SSH_PROFILE_ENTRY_TYPE,
18
+ } from "./profile.js";
19
+ import type { SshProfile } from "./profile.js";
20
+ import { executeRemoteBash, resolveRemoteBashConnection } from "./remote-bash.js";
21
+ import type { RemoteBashDependencies, RemoteBashResult } from "./remote-bash.js";
22
+ import { resolveSshCommand } from "./shared-ssh-core/index.js";
23
+
24
+ const STATUS_KEY = "pi-better-ssh";
25
+
26
+ const RemoteBashParams = Type.Object({
27
+ command: Type.String({ description: "Bash command string to execute remotely." }),
28
+ host: Type.Optional(Type.String({ description: "SSH Host alias or user@host target. Optional when an active SSH profile supplies the host." })),
29
+ workdir: Type.Optional(Type.String({ description: "Remote working directory applied before the command." })),
30
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout). Cancels only this SSH slave command." })),
31
+ env: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Remote environment variables exported before the command." })),
32
+ user: Type.Optional(Type.String({ description: "Structured SSH user override." })),
33
+ port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65_535, description: "Structured SSH port override." })),
34
+ identity_file: Type.Optional(Type.String({ description: "Structured SSH identity file path." })),
35
+ jump: Type.Optional(Type.String({ description: "Structured SSH jump host passed with -J." })),
36
+ options: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Additional SSH -o key/value options. Callers cannot disable required BatchMode, connect-timeout, no-TTY, or mux safety options." })),
37
+ });
38
+
39
+ const SshMuxParams = Type.Object({
40
+ action: Type.Union([Type.Literal("status"), Type.Literal("stop")]),
41
+ all: Type.Optional(Type.Boolean({ description: "Apply to every ControlMaster known in this Pi process for the current session." })),
42
+ host: Type.Optional(Type.String({ description: "SSH Host alias or user@host target. Defaults to the active profile." })),
43
+ user: Type.Optional(Type.String({ description: "Structured SSH user override." })),
44
+ port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65_535 })),
45
+ identity_file: Type.Optional(Type.String()),
46
+ jump: Type.Optional(Type.String()),
47
+ options: Type.Optional(Type.Record(Type.String(), Type.String())),
48
+ });
49
+
50
+ const SshProfileParams = Type.Object({
51
+ action: Type.Union([
52
+ Type.Literal("list"),
53
+ Type.Literal("use"),
54
+ Type.Literal("status"),
55
+ Type.Literal("clear"),
56
+ ]),
57
+ host: Type.Optional(Type.String({ description: "SSH config Host alias or user@host. Required for use." })),
58
+ workdir: Type.Optional(Type.String({ description: "Default remote working directory." })),
59
+ env: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Default remote environment variables." })),
60
+ });
61
+
62
+ export interface RegisterRemoteBashDependencies {
63
+ runner: RemoteBashDependencies["runner"];
64
+ controlPathRoot?: string;
65
+ getActiveProfile?: () => SshProfile | undefined;
66
+ muxRegistry?: SshMuxRegistry;
67
+ onResult?: (result: RemoteBashResult, params: { host?: string }, ctx: ExtensionContext) => void | Promise<void>;
68
+ }
69
+
70
+ export interface RegisterSshExtensionDependencies extends RegisterRemoteBashDependencies {
71
+ sshConfigPath?: string;
72
+ muxEntries?: SshMuxEntryMap;
73
+ }
74
+
75
+ export function registerRemoteBashTool(pi: ExtensionAPI, dependencies: RegisterRemoteBashDependencies): void {
76
+ pi.registerTool({
77
+ name: "remote_bash",
78
+ label: "Remote Bash",
79
+ description: `Run one short remote bash command synchronously over a reusable SSH ControlMaster. Pass an SSH Host alias or user@host, or omit host when an active SSH profile supplies it; optional workdir, env, timeout, and structured identity fields are supported. Required SSH safety options cannot be disabled. Returns remote output and exitCode without turning a non-zero remote exit into a tool failure. Output is truncated to the last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first), with full output saved locally. For long-running remote work, use bg_task_spawn (or bg_task_* lifecycle tools) with structured ssh instead. Pi's built-in bash remains local.`,
80
+ promptSnippet: "Run a short synchronous remote command through safe reusable SSH",
81
+ promptGuidelines: [
82
+ "Use remote_bash for short synchronous remote commands instead of writing ssh commands inside local bash.",
83
+ "Use bg_task_spawn with structured ssh for long-running or durable remote work instead of remote_bash.",
84
+ ],
85
+ parameters: RemoteBashParams,
86
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
87
+ const sessionScope = ctx.sessionManager.getSessionId();
88
+ const activeProfile = dependencies.getActiveProfile?.();
89
+ const result = await executeRemoteBash(params, {
90
+ runner: dependencies.runner,
91
+ sessionScope,
92
+ ...(dependencies.controlPathRoot ? { controlPathRoot: dependencies.controlPathRoot } : {}),
93
+ ...(activeProfile ? { activeProfile } : {}),
94
+ ...(dependencies.muxRegistry ? { muxRegistry: dependencies.muxRegistry } : {}),
95
+ }, signal);
96
+ await dependencies.onResult?.(result, params, ctx);
97
+ return {
98
+ content: [{ type: "text" as const, text: formatToolResult(result, params.timeout) }],
99
+ details: result,
100
+ };
101
+ },
102
+ });
103
+ }
104
+
105
+ export function registerSshExtension(pi: ExtensionAPI, dependencies: RegisterSshExtensionDependencies): void {
106
+ let activeProfile: SshProfile | undefined;
107
+ const muxRegistry = dependencies.muxRegistry ?? createSshMuxRegistry({
108
+ runner: dependencies.runner,
109
+ ...(dependencies.controlPathRoot ? { controlPathRoot: dependencies.controlPathRoot } : {}),
110
+ ...(dependencies.muxEntries ? { entries: dependencies.muxEntries } : {}),
111
+ });
112
+
113
+ registerRemoteBashTool(pi, {
114
+ ...dependencies,
115
+ muxRegistry,
116
+ getActiveProfile: () => activeProfile,
117
+ onResult: (result, params, ctx) => {
118
+ if (activeProfile && params.host === undefined) {
119
+ setFooter(ctx, activeProfile, result.mux.state);
120
+ }
121
+ },
122
+ });
123
+
124
+ const resolveMuxTarget = (params: {
125
+ host?: string;
126
+ user?: string;
127
+ port?: number;
128
+ identity_file?: string;
129
+ jump?: string;
130
+ options?: Record<string, string>;
131
+ }) => {
132
+ const host = params.host ?? activeProfile?.host;
133
+ if (!host) {
134
+ throw new Error("ssh_mux requires host (an SSH Host alias or user@host) or an active SSH profile");
135
+ }
136
+ const ssh = resolveRemoteBashConnection({
137
+ command: "true",
138
+ host,
139
+ ...(params.user !== undefined ? { user: params.user } : {}),
140
+ ...(params.port !== undefined ? { port: params.port } : {}),
141
+ ...(params.identity_file !== undefined ? { identity_file: params.identity_file } : {}),
142
+ ...(params.jump !== undefined ? { jump: params.jump } : {}),
143
+ ...(params.options !== undefined ? { options: params.options } : {}),
144
+ });
145
+ return resolveSshCommand({ command: "true", ssh });
146
+ };
147
+
148
+ const queryMux = async (profile: SshProfile, sessionScope: string) => {
149
+ try {
150
+ const status = await muxRegistry.statusTarget(resolveMuxTarget({ host: profile.host }), sessionScope);
151
+ return { state: status.state, target: status.target, detail: status.detail };
152
+ } catch (error) {
153
+ return {
154
+ state: "down" as const,
155
+ target: profile.host,
156
+ detail: error instanceof Error ? error.message : String(error),
157
+ };
158
+ }
159
+ };
160
+
161
+ const setFooter = (ctx: ExtensionContext, profile: SshProfile | undefined, muxState?: "up" | "down"): void => {
162
+ try {
163
+ ctx.ui.setStatus(STATUS_KEY, profile ? formatSshProfileChip(profile, muxState ?? "down") : undefined);
164
+ } catch {
165
+ // Footer status is best-effort in non-interactive and partial harness contexts.
166
+ }
167
+ };
168
+
169
+ const refreshFooter = async (ctx: ExtensionContext) => {
170
+ if (!activeProfile) {
171
+ setFooter(ctx, undefined);
172
+ return undefined;
173
+ }
174
+ const mux = await queryMux(activeProfile, ctx.sessionManager.getSessionId());
175
+ setFooter(ctx, activeProfile, mux.state);
176
+ return mux;
177
+ };
178
+
179
+ pi.registerTool({
180
+ name: "ssh_profile",
181
+ label: "SSH Profile",
182
+ description: "List SSH config Host aliases and manage the active session default host, workdir, and environment. Profile state is stored in the Pi session and survives /reload; it does not create a separate host inventory.",
183
+ promptSnippet: "List or manage the active SSH default profile",
184
+ parameters: SshProfileParams,
185
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
186
+ if (params.action === "list") {
187
+ const aliases = listSshHostAliases(dependencies.sshConfigPath);
188
+ const details = { action: "list" as const, aliases, active: activeProfile ?? null };
189
+ return { content: [{ type: "text" as const, text: formatProfileList(aliases, activeProfile) }], details };
190
+ }
191
+ if (params.action === "use") {
192
+ const aliases = listSshHostAliases(dependencies.sshConfigPath);
193
+ activeProfile = createSshProfile(params, aliases, dependencies.sshConfigPath);
194
+ pi.appendEntry(SSH_PROFILE_ENTRY_TYPE, profileStateEntry(activeProfile));
195
+ const mux = await refreshFooter(ctx);
196
+ const details = { action: "use" as const, active: activeProfile, mux };
197
+ return { content: [{ type: "text" as const, text: `Active SSH profile: ${formatSshProfileChip(activeProfile, mux?.state ?? "down")}` }], details };
198
+ }
199
+ if (params.action === "clear") {
200
+ activeProfile = undefined;
201
+ pi.appendEntry(SSH_PROFILE_ENTRY_TYPE, profileStateEntry(undefined));
202
+ setFooter(ctx, undefined);
203
+ const details = { action: "clear" as const, active: null };
204
+ return { content: [{ type: "text" as const, text: "Active SSH profile cleared." }], details };
205
+ }
206
+
207
+ const mux = activeProfile ? await refreshFooter(ctx) : undefined;
208
+ const details = { action: "status" as const, active: activeProfile ?? null, ...(mux ? { mux } : {}) };
209
+ return {
210
+ content: [{
211
+ type: "text" as const,
212
+ text: activeProfile
213
+ ? formatSshProfileChip(activeProfile, mux?.state ?? "down")
214
+ : "No active SSH profile.",
215
+ }],
216
+ details,
217
+ };
218
+ },
219
+ });
220
+
221
+ pi.registerTool({
222
+ name: "ssh_mux",
223
+ label: "SSH Mux",
224
+ description: "Inspect or stop the SSH ControlMaster for one target, the active profile, or all masters observed in this Pi process for the current session.",
225
+ promptSnippet: "Inspect or stop reusable SSH ControlMaster connections",
226
+ parameters: SshMuxParams,
227
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
228
+ const identityFields = [params.host, params.user, params.port, params.identity_file, params.jump, params.options];
229
+ if (params.all && identityFields.some((value) => value !== undefined)) {
230
+ throw new Error("ssh_mux all cannot be combined with target identity fields");
231
+ }
232
+ const sessionScope = ctx.sessionManager.getSessionId();
233
+ if (params.all) {
234
+ const results = params.action === "status"
235
+ ? await muxRegistry.statusAll(sessionScope)
236
+ : await muxRegistry.stopAll(sessionScope);
237
+ const masters = results.map(muxResultDetails);
238
+ if (activeProfile) {
239
+ const activeTarget = resolveMuxTarget({ host: activeProfile.host }).identity.target;
240
+ const activeMux = masters.find((master) => master.target === activeTarget);
241
+ if (params.action === "stop") setFooter(ctx, activeProfile, "down");
242
+ else if (activeMux) setFooter(ctx, activeProfile, activeMux.state === "up" ? "up" : "down");
243
+ }
244
+ const details = { action: params.action, scope: "all" as const, masters };
245
+ return { content: [{ type: "text" as const, text: formatMuxResults(masters) }], details };
246
+ }
247
+
248
+ const resolved = resolveMuxTarget(params);
249
+ const result = params.action === "status"
250
+ ? await muxRegistry.statusTarget(resolved, sessionScope)
251
+ : await muxRegistry.stopTarget(resolved, sessionScope);
252
+ const masters = [muxResultDetails(result)];
253
+ if (activeProfile) {
254
+ const activeTarget = resolveMuxTarget({ host: activeProfile.host }).identity.target;
255
+ if (resolved.identity.target === activeTarget) {
256
+ setFooter(ctx, activeProfile, result.state === "up" ? "up" : "down");
257
+ }
258
+ }
259
+ const details = { action: params.action, scope: "target" as const, masters };
260
+ return { content: [{ type: "text" as const, text: formatMuxResults(masters) }], details };
261
+ },
262
+ });
263
+
264
+ const restore = async (ctx: ExtensionContext): Promise<void> => {
265
+ activeProfile = restoreSshProfile(ctx.sessionManager.getBranch());
266
+ await refreshFooter(ctx);
267
+ };
268
+ pi.on("session_start", async (_event, ctx) => restore(ctx));
269
+ pi.on("session_tree", async (_event, ctx) => restore(ctx));
270
+ pi.on("session_shutdown", async (_event, ctx) => setFooter(ctx, undefined));
271
+ }
272
+
273
+ export default function sshExtension(pi: ExtensionAPI): void {
274
+ registerSshExtension(pi, { runner: createProcessRemoteRunner() });
275
+ }
276
+
277
+ function formatProfileList(aliases: string[], active: SshProfile | undefined): string {
278
+ const lines = aliases.length > 0
279
+ ? aliases.map((alias) => `${active?.host === alias ? "*" : "-"} ${alias}`)
280
+ : ["(no literal Host aliases found in ~/.ssh/config)"];
281
+ if (active && !aliases.includes(active.host)) lines.unshift(`* ${active.host} (active user@host profile)`);
282
+ return lines.join("\n");
283
+ }
284
+
285
+ function muxResultDetails(result: { state: string; target: string; controlPath: string; detail: string }) {
286
+ return {
287
+ target: result.target,
288
+ state: result.state,
289
+ controlPath: result.controlPath,
290
+ detail: result.detail,
291
+ };
292
+ }
293
+
294
+ function formatMuxResults(results: Array<{ target: string; state: string; detail: string }>): string {
295
+ if (results.length === 0) return "No SSH ControlMasters are known for this session.";
296
+ return results.map((result) => `${result.target}: mux ${result.state} - ${result.detail}`).join("\n");
297
+ }
298
+
299
+ function formatToolResult(result: RemoteBashResult, timeoutSeconds?: number): string {
300
+ let text = result.output || "(no output)";
301
+ if (result.truncated && result.truncation && result.fullOutputPath) {
302
+ const startLine = result.truncation.totalLines - result.truncation.outputLines + 1;
303
+ const endLine = result.truncation.totalLines;
304
+ text += result.truncation.truncatedBy === "lines"
305
+ ? `\n\n[Showing lines ${startLine}-${endLine} of ${result.truncation.totalLines}. Full output: ${result.fullOutputPath}]`
306
+ : `\n\n[Showing lines ${startLine}-${endLine} of ${result.truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
307
+ }
308
+ if (result.timedOut) return `${text}\n\nCommand timed out after ${timeoutSeconds} seconds`;
309
+ if (result.cancelled) return `${text}\n\nCommand cancelled`;
310
+ if (result.exitCode !== 0 && result.exitCode !== undefined) {
311
+ return `${text}\n\nCommand exited with code ${result.exitCode}`;
312
+ }
313
+ return text;
314
+ }
@@ -0,0 +1,86 @@
1
+ import { createSshMuxController } from "./shared-ssh-core/index.js";
2
+ import type {
3
+ RemoteRunner,
4
+ ResolvedSshCommand,
5
+ SshMuxCleanupResult,
6
+ SshMuxController,
7
+ SshMuxStatus,
8
+ } from "./shared-ssh-core/index.js";
9
+
10
+ const GLOBAL_MUX_ENTRIES = Symbol.for("pi-better-ssh.mux-entries.v1");
11
+
12
+ export interface KnownSshMuxEntry {
13
+ sessionScope: string;
14
+ resolved: ResolvedSshCommand;
15
+ }
16
+
17
+ export type SshMuxEntryMap = Map<string, KnownSshMuxEntry>;
18
+
19
+ export interface SshMuxRegistry {
20
+ controllerFor(resolved: ResolvedSshCommand, sessionScope: string): SshMuxController;
21
+ statusTarget(resolved: ResolvedSshCommand, sessionScope: string): Promise<SshMuxStatus>;
22
+ stopTarget(resolved: ResolvedSshCommand, sessionScope: string): Promise<SshMuxCleanupResult>;
23
+ statusAll(sessionScope: string): Promise<SshMuxStatus[]>;
24
+ stopAll(sessionScope: string): Promise<SshMuxCleanupResult[]>;
25
+ }
26
+
27
+ export function createSshMuxRegistry(options: {
28
+ runner: RemoteRunner;
29
+ controlPathRoot?: string;
30
+ entries?: SshMuxEntryMap;
31
+ }): SshMuxRegistry {
32
+ const entries = options.entries ?? processMuxEntries();
33
+
34
+ const makeController = (resolved: ResolvedSshCommand, sessionScope: string): SshMuxController =>
35
+ createSshMuxController({
36
+ ...resolved,
37
+ runner: options.runner,
38
+ sessionScope,
39
+ ...(options.controlPathRoot ? { controlPathRoot: options.controlPathRoot } : {}),
40
+ });
41
+
42
+ const controllerFor = (resolved: ResolvedSshCommand, sessionScope: string): SshMuxController => {
43
+ const controller = makeController(resolved, sessionScope);
44
+ entries.set(controller.controlPath, { sessionScope, resolved });
45
+ return controller;
46
+ };
47
+
48
+ const knownForSession = (sessionScope: string): Array<[string, KnownSshMuxEntry]> =>
49
+ [...entries].filter(([, entry]) => entry.sessionScope === sessionScope);
50
+
51
+ return {
52
+ controllerFor,
53
+ statusTarget: (resolved, sessionScope) => controllerFor(resolved, sessionScope).status(),
54
+ stopTarget: async (resolved, sessionScope) => {
55
+ const controller = controllerFor(resolved, sessionScope);
56
+ try {
57
+ return await controller.cleanup();
58
+ } finally {
59
+ entries.delete(controller.controlPath);
60
+ }
61
+ },
62
+ statusAll: async (sessionScope) => {
63
+ const statuses: SshMuxStatus[] = [];
64
+ for (const [, entry] of knownForSession(sessionScope)) {
65
+ statuses.push(await makeController(entry.resolved, sessionScope).status());
66
+ }
67
+ return statuses;
68
+ },
69
+ stopAll: async (sessionScope) => {
70
+ const results: SshMuxCleanupResult[] = [];
71
+ for (const [controlPath, entry] of knownForSession(sessionScope)) {
72
+ try {
73
+ results.push(await makeController(entry.resolved, sessionScope).cleanup());
74
+ } finally {
75
+ entries.delete(controlPath);
76
+ }
77
+ }
78
+ return results;
79
+ },
80
+ };
81
+ }
82
+
83
+ function processMuxEntries(): SshMuxEntryMap {
84
+ const globalState = globalThis as typeof globalThis & { [GLOBAL_MUX_ENTRIES]?: SshMuxEntryMap };
85
+ return globalState[GLOBAL_MUX_ENTRIES] ??= new Map();
86
+ }
@@ -0,0 +1,166 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { closeSync, mkdtempSync, openSync, rmSync, writeSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
6
+ import type {
7
+ CommandOutputCapture,
8
+ CommandResult,
9
+ CommandSpec,
10
+ RemoteRunner,
11
+ SpawnedProcess,
12
+ } from "./shared-ssh-core/index.js";
13
+
14
+ const DEFAULT_CAPTURE_BYTES = DEFAULT_MAX_BYTES * 2;
15
+
16
+ export function createProcessRemoteRunner(): RemoteRunner {
17
+ return {
18
+ spawn(): SpawnedProcess {
19
+ throw new Error("pi-better-ssh does not support detached remote processes; use bg_task_spawn with structured ssh");
20
+ },
21
+ runOnce: runProcessOnce,
22
+ };
23
+ }
24
+
25
+ function runProcessOnce(
26
+ spec: CommandSpec,
27
+ maxBufferBytes = DEFAULT_CAPTURE_BYTES,
28
+ timeoutMs?: number,
29
+ signal?: AbortSignal,
30
+ ): Promise<CommandResult> {
31
+ const argv = validateCommandSpec(spec);
32
+ const startedAt = Date.now();
33
+ const child = nodeSpawn(argv[0]!, argv.slice(1), {
34
+ cwd: spec.cwd,
35
+ env: { ...process.env, ...spec.env },
36
+ shell: false,
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ windowsHide: true,
39
+ });
40
+ const retainedBytes = Math.max(DEFAULT_CAPTURE_BYTES, Math.floor(maxBufferBytes));
41
+ const combined = new CommandOutputCaptureBuffer(retainedBytes);
42
+ const stdout = new TailBuffer(retainedBytes);
43
+ const stderr = new TailBuffer(retainedBytes);
44
+ let timedOut = false;
45
+ let settled = false;
46
+
47
+ child.stdout?.on("data", (chunk: Buffer) => {
48
+ stdout.append(chunk);
49
+ combined.append(chunk);
50
+ });
51
+ child.stderr?.on("data", (chunk: Buffer) => {
52
+ stderr.append(chunk);
53
+ combined.append(chunk);
54
+ });
55
+
56
+ return new Promise((resolve, reject) => {
57
+ const stopSlave = (): void => {
58
+ if (!settled) child.kill("SIGTERM");
59
+ };
60
+ const onAbort = (): void => stopSlave();
61
+ const timeout = timeoutMs === undefined ? undefined : setTimeout(() => {
62
+ timedOut = true;
63
+ stopSlave();
64
+ }, Math.max(1, timeoutMs));
65
+ timeout?.unref();
66
+ if (signal?.aborted) stopSlave();
67
+ else signal?.addEventListener("abort", onAbort, { once: true });
68
+
69
+ const finish = (): void => {
70
+ settled = true;
71
+ if (timeout) clearTimeout(timeout);
72
+ signal?.removeEventListener("abort", onAbort);
73
+ };
74
+ child.on("error", (error) => {
75
+ finish();
76
+ combined.discard();
77
+ reject(error);
78
+ });
79
+ child.on("close", (exitCode, childSignal) => {
80
+ finish();
81
+ resolve({
82
+ exitCode,
83
+ signal: childSignal,
84
+ stdout: stdout.text(),
85
+ stderr: stderr.text(),
86
+ startedAt,
87
+ endedAt: Date.now(),
88
+ ...(timedOut ? { timedOut: true } : {}),
89
+ outputCapture: combined.finish(),
90
+ });
91
+ });
92
+ });
93
+ }
94
+
95
+ function validateCommandSpec(spec: CommandSpec): string[] {
96
+ if (spec.shell !== false || !spec.argv?.[0]) {
97
+ throw new Error("pi-better-ssh runner requires a non-empty shell:false argv");
98
+ }
99
+ return spec.argv;
100
+ }
101
+
102
+ class TailBuffer {
103
+ private chunks: Buffer[] = [];
104
+ private bytes = 0;
105
+
106
+ constructor(private readonly maxBytes: number) {}
107
+
108
+ append(chunk: Buffer): void {
109
+ this.chunks.push(Buffer.from(chunk));
110
+ this.bytes += chunk.length;
111
+ while (this.bytes > this.maxBytes && this.chunks.length > 1) {
112
+ const removed = this.chunks.shift()!;
113
+ this.bytes -= removed.length;
114
+ }
115
+ if (this.bytes > this.maxBytes && this.chunks.length === 1) {
116
+ this.chunks[0] = this.chunks[0]!.subarray(this.bytes - this.maxBytes);
117
+ this.bytes = this.chunks[0]!.length;
118
+ }
119
+ }
120
+
121
+ text(): string {
122
+ return Buffer.concat(this.chunks).toString("utf8");
123
+ }
124
+ }
125
+
126
+ class CommandOutputCaptureBuffer extends TailBuffer {
127
+ private readonly directory = mkdtempSync(join(tmpdir(), "pi-remote-bash-"));
128
+ private readonly path = join(this.directory, "output.log");
129
+ private readonly fd = openSync(this.path, "w", 0o600);
130
+ private totalBytes = 0;
131
+ private newlineCount = 0;
132
+ private closed = false;
133
+
134
+ override append(chunk: Buffer): void {
135
+ super.append(chunk);
136
+ writeSync(this.fd, chunk);
137
+ this.totalBytes += chunk.length;
138
+ for (const byte of chunk) {
139
+ if (byte === 0x0a) this.newlineCount += 1;
140
+ }
141
+ }
142
+
143
+ finish(): CommandOutputCapture {
144
+ this.close();
145
+ const totalLines = this.newlineCount + 1;
146
+ const truncated = this.totalBytes > DEFAULT_MAX_BYTES || totalLines > DEFAULT_MAX_LINES;
147
+ if (!truncated) rmSync(this.directory, { recursive: true, force: true });
148
+ return {
149
+ output: this.text(),
150
+ totalBytes: this.totalBytes,
151
+ totalLines,
152
+ ...(truncated ? { fullOutputPath: this.path } : {}),
153
+ };
154
+ }
155
+
156
+ discard(): void {
157
+ this.close();
158
+ rmSync(this.directory, { recursive: true, force: true });
159
+ }
160
+
161
+ private close(): void {
162
+ if (this.closed) return;
163
+ this.closed = true;
164
+ closeSync(this.fd);
165
+ }
166
+ }