6db649c9ed
- Added configurable ask timeout and notification settings to control ask tool behavior and user notifications. - Added AskSettings interface with timeout (in seconds, default 30) and notification method properties to settings manager. - Added ask timeout and notification configuration options to settings UI with values for timeout (off, 15, 30, 60, 120 seconds) and notification methods (auto, bell, osc99, osc9, off). - Refactored process execution across multiple tools (exec, fetch, grep, read, youtube scraper) to use centralized ptree.execText() API instead of custom implementations. - Refactored ChildProcess class in ptree to use public readonly properties and Promise.withResolvers() for cleaner exit handling.
125 lines
3.5 KiB
TypeScript
125 lines
3.5 KiB
TypeScript
import { logger, ptree } from "@oh-my-pi/pi-utils";
|
|
import { OutputSink } from "../session/streaming-output";
|
|
import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
|
|
import { hasSshfs, mountRemote } from "./sshfs-mount";
|
|
|
|
export interface SSHExecutorOptions {
|
|
/** Timeout in milliseconds */
|
|
timeout?: number;
|
|
/** Callback for streaming output chunks (already sanitized) */
|
|
onChunk?: (chunk: string) => void;
|
|
/** AbortSignal for cancellation */
|
|
signal?: AbortSignal;
|
|
/** Remote path to mount when sshfs is available */
|
|
remotePath?: string;
|
|
/** Wrap commands in a POSIX shell for compat mode */
|
|
compatEnabled?: boolean;
|
|
/** Artifact path/id for full output storage */
|
|
artifactPath?: string;
|
|
artifactId?: string;
|
|
}
|
|
|
|
export interface SSHResult {
|
|
/** Combined stdout + stderr output (sanitized, possibly truncated) */
|
|
output: string;
|
|
/** Process exit code (undefined if killed/cancelled) */
|
|
exitCode: number | undefined;
|
|
/** Whether the command was cancelled via signal */
|
|
cancelled: boolean;
|
|
/** Whether the output was truncated */
|
|
truncated: boolean;
|
|
/** Total number of lines in the output stream */
|
|
totalLines: number;
|
|
/** Total number of bytes in the output stream */
|
|
totalBytes: number;
|
|
/** Number of lines included in the output text */
|
|
outputLines: number;
|
|
/** Number of bytes included in the output text */
|
|
outputBytes: number;
|
|
/** Artifact ID if full output was saved to artifact storage */
|
|
artifactId?: string;
|
|
}
|
|
|
|
function quoteForCompatShell(command: string): string {
|
|
if (command.length === 0) {
|
|
return "''";
|
|
}
|
|
const escaped = command.replace(/'/g, "'\\''");
|
|
return `'${escaped}'`;
|
|
}
|
|
|
|
function buildCompatCommand(shell: "bash" | "sh", command: string): string {
|
|
return `${shell} -c ${quoteForCompatShell(command)}`;
|
|
}
|
|
|
|
export async function executeSSH(
|
|
host: SSHConnectionTarget,
|
|
command: string,
|
|
options?: SSHExecutorOptions,
|
|
): Promise<SSHResult> {
|
|
await ensureConnection(host);
|
|
if (hasSshfs()) {
|
|
try {
|
|
await mountRemote(host, options?.remotePath ?? "/");
|
|
} catch (err) {
|
|
logger.warn("SSHFS mount failed", { host: host.name, error: String(err) });
|
|
}
|
|
}
|
|
|
|
let resolvedCommand = command;
|
|
if (options?.compatEnabled) {
|
|
const info = await ensureHostInfo(host);
|
|
if (info.compatShell) {
|
|
resolvedCommand = buildCompatCommand(info.compatShell, command);
|
|
} else {
|
|
logger.warn("SSH compat enabled without detected compat shell", { host: host.name });
|
|
}
|
|
}
|
|
|
|
using child = ptree.spawnAttached(["ssh", ...(await buildRemoteCommand(host, resolvedCommand))], {
|
|
signal: options?.signal,
|
|
timeout: options?.timeout,
|
|
});
|
|
|
|
const sink = new OutputSink({
|
|
onChunk: options?.onChunk,
|
|
artifactPath: options?.artifactPath,
|
|
artifactId: options?.artifactId,
|
|
});
|
|
|
|
await Promise.allSettled([child.stdout.pipeTo(sink.createInput()), child.stderr.pipeTo(sink.createInput())]).catch(
|
|
() => {},
|
|
);
|
|
|
|
try {
|
|
return {
|
|
exitCode: await child.exited,
|
|
cancelled: false,
|
|
...(await sink.dump()),
|
|
};
|
|
} catch (err) {
|
|
if (err instanceof ptree.Exception) {
|
|
if (err instanceof ptree.TimeoutError) {
|
|
return {
|
|
exitCode: undefined,
|
|
cancelled: true,
|
|
...(await sink.dump(`SSH: ${err.message}`)),
|
|
};
|
|
}
|
|
if (err.aborted) {
|
|
return {
|
|
exitCode: undefined,
|
|
cancelled: true,
|
|
...(await sink.dump(`Command aborted: ${err.message}`)),
|
|
};
|
|
}
|
|
return {
|
|
exitCode: err.exitCode,
|
|
cancelled: false,
|
|
...(await sink.dump(`Unexpected error: ${err.message}`)),
|
|
};
|
|
}
|
|
throw err;
|
|
}
|
|
}
|