Files
oh-my-pi/packages/coding-agent/src/tools/ssh.ts
T
can1357 f0ff398607 fix(coding-agent/tools): stripped duplicate output notices from TUI tool renderers
- Added stripOutputNotice to output-meta to remove appended truncation notices when output metadata is available.
- Updated bash, eval, browser, read, and ssh renderers to strip the notice before display so the styled warning line is not duplicated.
- Left fallback behavior unchanged so outputs without a notice continue through unchanged.
2026-05-15 03:16:16 +02:00

311 lines
10 KiB
TypeScript

import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { prompt } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import type { SSHHost } from "../capability/ssh";
import { sshCapability } from "../capability/ssh";
import { loadCapability } from "../discovery";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { Theme } from "../modes/theme/theme";
import sshDescriptionBase from "../prompts/tools/ssh.md" with { type: "text" };
import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output";
import type { SSHHostInfo } from "../ssh/connection-manager";
import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager";
import { executeSSH } from "../ssh/ssh-executor";
import { renderStatusLine } from "../tui";
import { CachedOutputBlock } from "../tui/output-block";
import type { ToolSession } from ".";
import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
import { clampTimeout } from "./tool-timeouts";
const sshSchema = Type.Object({
host: Type.String({ description: "ssh host", examples: ["my-server", "prod-1"] }),
command: Type.String({ description: "remote command", examples: ["ls -la", "uptime"] }),
cwd: Type.Optional(Type.String({ description: "remote working directory", examples: ["/var/log"] })),
timeout: Type.Optional(Type.Number({ description: "timeout in seconds", default: 60 })),
});
export interface SSHToolDetails {
meta?: OutputMeta;
}
async function formatHostEntry(host: SSHHost): Promise<string> {
const info = await getHostInfoForHost(host);
let shell: string;
if (!info) {
shell = "detecting...";
} else if (info.os === "windows") {
if (info.compatEnabled) {
const compatShell = info.compatShell || "bash";
shell = `windows/${compatShell}`;
} else if (info.shell === "powershell") {
shell = "windows/powershell";
} else {
shell = "windows/cmd";
}
} else if (info.os === "linux") {
shell = `linux/${info.shell}`;
} else if (info.os === "macos") {
shell = `macos/${info.shell}`;
} else {
shell = `unknown/${info.shell}`;
}
return `- ${host.name} (${host.host}) | ${shell}`;
}
async function formatDescription(hosts: SSHHost[]): Promise<string> {
const baseDescription = prompt.render(sshDescriptionBase);
if (hosts.length === 0) {
return baseDescription;
}
const hostList = (await Promise.all(hosts.map(formatHostEntry))).join("\n");
return `${baseDescription}\n\nAvailable hosts:\n${hostList}`;
}
function quoteRemotePath(value: string): string {
if (value.length === 0) {
return "''";
}
const escaped = value.replace(/'/g, "'\\''");
return `'${escaped}'`;
}
function quotePowerShellPath(value: string): string {
if (value.length === 0) {
return "''";
}
const escaped = value.replace(/'/g, "''");
return `'${escaped}'`;
}
function quoteCmdPath(value: string): string {
const escaped = value.replace(/"/g, '""');
return `"${escaped}"`;
}
function buildRemoteCommand(command: string, cwd: string | undefined, info: SSHHostInfo): string {
if (!cwd) return command;
if (info.os === "windows" && !info.compatEnabled) {
if (info.shell === "powershell") {
return `Set-Location -Path ${quotePowerShellPath(cwd)}; ${command}`;
}
return `cd /d ${quoteCmdPath(cwd)} && ${command}`;
}
return `cd -- ${quoteRemotePath(cwd)} && ${command}`;
}
async function loadHosts(session: ToolSession): Promise<{
hostNames: string[];
hostsByName: Map<string, SSHHost>;
}> {
const result = await loadCapability<SSHHost>(sshCapability.id, { cwd: session.cwd });
const hostsByName = new Map<string, SSHHost>();
for (const host of result.items) {
if (!hostsByName.has(host.name)) {
hostsByName.set(host.name, host);
}
}
const hostNames = Array.from(hostsByName.keys()).sort();
return { hostNames, hostsByName };
}
type SshToolParams = Static<typeof sshSchema>;
export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
readonly name = "ssh";
readonly summary = "Execute a command on a remote host over SSH";
readonly loadMode = "discoverable";
readonly label = "SSH";
readonly parameters = sshSchema;
readonly concurrency = "exclusive";
readonly strict = true;
readonly #allowedHosts: Set<string>;
constructor(
private readonly session: ToolSession,
private readonly hostNames: string[],
private readonly hostsByName: Map<string, SSHHost>,
readonly description: string,
) {
this.#allowedHosts = new Set(this.hostNames);
}
async execute(
_toolCallId: string,
{ host, command, cwd, timeout: rawTimeout = 60 }: SshToolParams,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<SSHToolDetails>,
_ctx?: AgentToolContext,
): Promise<AgentToolResult<SSHToolDetails>> {
if (!this.#allowedHosts.has(host)) {
throw new ToolError(`Unknown SSH host: ${host}. Available hosts: ${this.hostNames.join(", ")}`);
}
const hostConfig = this.hostsByName.get(host);
if (!hostConfig) {
throw new ToolError(`SSH host not loaded: ${host}`);
}
const hostInfo = await ensureHostInfo(hostConfig);
const remoteCommand = buildRemoteCommand(command, cwd, hostInfo);
// Clamp to reasonable range: 1s - 3600s (1 hour)
const timeoutSec = clampTimeout("ssh", rawTimeout);
const timeoutMs = timeoutSec * 1000;
const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("ssh")) ?? {};
const result = await executeSSH(hostConfig, remoteCommand, {
timeout: timeoutMs,
signal,
compatEnabled: hostInfo.compatEnabled,
artifactPath,
artifactId,
onChunk: streamTailUpdates(tailBuffer, onUpdate),
});
if (result.cancelled) {
throw new ToolError(result.output || "Command aborted");
}
const outputText = result.output || "(no output)";
const details: SSHToolDetails = {};
const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
if (result.exitCode !== 0 && result.exitCode !== undefined) {
throw new ToolError(`${outputText}\n\nCommand exited with code ${result.exitCode}`);
}
return resultBuilder.done();
}
}
export async function loadSshTool(session: ToolSession): Promise<SshTool | null> {
const { hostNames, hostsByName } = await loadHosts(session);
if (hostNames.length === 0) {
return null;
}
const descriptionHosts = hostNames
.map(name => hostsByName.get(name))
.filter((host): host is SSHHost => host !== undefined);
const description = await formatDescription(descriptionHosts);
return new SshTool(session, hostNames, hostsByName, description);
}
// =============================================================================
// TUI Renderer
// =============================================================================
interface SshRenderArgs {
host?: string;
command?: string;
timeout?: number;
}
interface SshRenderContext {
/** Visual lines for truncated output (pre-computed by tool-execution) */
visualLines?: string[];
/** Number of lines skipped */
skippedCount?: number;
/** Total visual lines */
totalVisualLines?: number;
}
export const sshToolRenderer = {
renderCall(args: SshRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const host = args.host || "…";
const command = args.command || "…";
const text = renderStatusLine({ icon: "pending", title: "SSH", description: `[${host}] $ ${command}` }, uiTheme);
return new Text(text, 0, 0);
},
renderResult(
result: {
content: Array<{ type: string; text?: string }>;
details?: SSHToolDetails;
},
options: RenderResultOptions & { renderContext?: SshRenderContext },
uiTheme: Theme,
args?: SshRenderArgs,
): Component {
const details = result.details;
const host = args?.host || "…";
const command = args?.command || "…";
const header = renderStatusLine(
{ icon: "success", title: "SSH", description: `[${host}] $ ${command}` },
uiTheme,
);
const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
const outputBlock = new CachedOutputBlock();
return {
render: (width: number): string[] => {
// REACTIVE: read mutable options at render time
const { expanded, renderContext } = options;
// Strip LLM-facing notice so we don't echo it next to the styled warning.
const output = stripOutputNotice(textContent, details?.meta).trimEnd();
const outputLines: string[] = [];
if (output) {
if (expanded) {
outputLines.push(...output.split("\n").map(line => uiTheme.fg("toolOutput", line)));
} else if (renderContext?.visualLines) {
const { visualLines, skippedCount = 0, totalVisualLines = visualLines.length } = renderContext;
if (skippedCount > 0) {
outputLines.push(
uiTheme.fg(
"dim",
`… (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`,
),
);
}
const styledVisual = visualLines.map(line =>
line.includes("\x1b[") ? line : uiTheme.fg("toolOutput", line),
);
outputLines.push(...styledVisual);
} else {
const outputLinesRaw = output.split("\n");
const maxLines = 5;
const displayLines = outputLinesRaw.slice(0, maxLines);
const remaining = outputLinesRaw.length - maxLines;
outputLines.push(...displayLines.map(line => uiTheme.fg("toolOutput", line)));
if (remaining > 0) {
outputLines.push(uiTheme.fg("dim", `… (${remaining} more lines) (ctrl+o to expand)`));
}
}
}
if (details?.meta?.truncation) {
const warning = formatStyledTruncationWarning(details.meta, uiTheme);
if (warning) outputLines.push(warning);
}
return outputBlock.render(
{
header,
state: "success",
sections: [{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines }],
width,
},
uiTheme,
);
},
invalidate: () => {
outputBlock.invalidate();
},
};
},
mergeCallAndResult: true,
};