@oh-my-pi/pi-coding-agent 3.32.0 → 3.34.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 (74) hide show
  1. package/CHANGELOG.md +49 -9
  2. package/README.md +12 -0
  3. package/docs/custom-tools.md +1 -1
  4. package/docs/extensions.md +4 -4
  5. package/docs/hooks.md +2 -2
  6. package/docs/sdk.md +4 -8
  7. package/examples/custom-tools/README.md +2 -2
  8. package/examples/extensions/README.md +1 -1
  9. package/examples/extensions/todo.ts +1 -1
  10. package/examples/hooks/custom-compaction.ts +4 -2
  11. package/examples/hooks/handoff.ts +1 -1
  12. package/examples/hooks/qna.ts +1 -1
  13. package/examples/sdk/02-custom-model.ts +1 -1
  14. package/examples/sdk/README.md +1 -1
  15. package/package.json +5 -5
  16. package/src/capability/ssh.ts +42 -0
  17. package/src/cli/file-processor.ts +1 -1
  18. package/src/cli/list-models.ts +1 -1
  19. package/src/core/agent-session.ts +21 -6
  20. package/src/core/auth-storage.ts +1 -1
  21. package/src/core/compaction/branch-summarization.ts +2 -2
  22. package/src/core/compaction/compaction.ts +2 -2
  23. package/src/core/compaction/utils.ts +1 -1
  24. package/src/core/custom-tools/types.ts +1 -1
  25. package/src/core/extensions/runner.ts +1 -1
  26. package/src/core/extensions/types.ts +1 -1
  27. package/src/core/extensions/wrapper.ts +1 -1
  28. package/src/core/file-mentions.ts +147 -5
  29. package/src/core/hooks/runner.ts +2 -2
  30. package/src/core/hooks/types.ts +1 -1
  31. package/src/core/index.ts +11 -0
  32. package/src/core/messages.ts +1 -1
  33. package/src/core/model-registry.ts +1 -1
  34. package/src/core/model-resolver.ts +9 -4
  35. package/src/core/sdk.ts +26 -2
  36. package/src/core/session-manager.ts +3 -2
  37. package/src/core/settings-manager.ts +70 -0
  38. package/src/core/ssh/connection-manager.ts +466 -0
  39. package/src/core/ssh/ssh-executor.ts +190 -0
  40. package/src/core/ssh/sshfs-mount.ts +162 -0
  41. package/src/core/ssh-executor.ts +5 -0
  42. package/src/core/system-prompt.ts +424 -1
  43. package/src/core/title-generator.ts +109 -55
  44. package/src/core/tools/index.test.ts +1 -0
  45. package/src/core/tools/index.ts +3 -0
  46. package/src/core/tools/output.ts +37 -2
  47. package/src/core/tools/read.ts +24 -11
  48. package/src/core/tools/renderers.ts +2 -0
  49. package/src/core/tools/ssh.ts +302 -0
  50. package/src/core/tools/task/index.ts +1 -1
  51. package/src/core/tools/task/render.ts +10 -16
  52. package/src/core/tools/task/types.ts +1 -1
  53. package/src/core/tools/task/worker.ts +1 -1
  54. package/src/core/voice.ts +1 -1
  55. package/src/discovery/index.ts +3 -0
  56. package/src/discovery/ssh.ts +162 -0
  57. package/src/main.ts +2 -1
  58. package/src/modes/interactive/components/assistant-message.ts +1 -1
  59. package/src/modes/interactive/components/bash-execution.ts +9 -10
  60. package/src/modes/interactive/components/custom-message.ts +1 -1
  61. package/src/modes/interactive/components/footer.ts +1 -1
  62. package/src/modes/interactive/components/hook-message.ts +1 -1
  63. package/src/modes/interactive/components/model-selector.ts +1 -1
  64. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  65. package/src/modes/interactive/components/status-line.ts +1 -1
  66. package/src/modes/interactive/components/tree-selector.ts +9 -12
  67. package/src/modes/interactive/interactive-mode.ts +5 -2
  68. package/src/modes/interactive/theme/theme.ts +2 -2
  69. package/src/modes/print-mode.ts +1 -1
  70. package/src/modes/rpc/rpc-client.ts +1 -1
  71. package/src/modes/rpc/rpc-types.ts +1 -1
  72. package/src/prompts/system-prompt.md +4 -0
  73. package/src/prompts/tools/ssh.md +74 -0
  74. package/src/utils/image-resize.ts +1 -1
@@ -2,17 +2,54 @@
2
2
  * Generate session titles using a smol, fast model.
3
3
  */
4
4
 
5
- import type { Model } from "@mariozechner/pi-ai";
6
- import { completeSimple } from "@mariozechner/pi-ai";
5
+ import type { Api, Model } from "@oh-my-pi/pi-ai";
6
+ import { completeSimple } from "@oh-my-pi/pi-ai";
7
7
  import titleSystemPrompt from "../prompts/title-system.md" with { type: "text" };
8
8
  import { logger } from "./logger";
9
9
  import type { ModelRegistry } from "./model-registry";
10
- import { findSmolModel } from "./model-resolver";
10
+ import { parseModelString, SMOL_MODEL_PRIORITY } from "./model-resolver";
11
11
 
12
12
  const TITLE_SYSTEM_PROMPT = titleSystemPrompt;
13
13
 
14
14
  const MAX_INPUT_CHARS = 2000;
15
15
 
16
+ function getTitleModelCandidates(registry: ModelRegistry, savedSmolModel?: string): Model<Api>[] {
17
+ const availableModels = registry.getAvailable();
18
+ if (availableModels.length === 0) return [];
19
+
20
+ const candidates: Model<Api>[] = [];
21
+ const addCandidate = (model?: Model<Api>): void => {
22
+ if (!model) return;
23
+ const exists = candidates.some((candidate) => candidate.provider === model.provider && candidate.id === model.id);
24
+ if (!exists) {
25
+ candidates.push(model);
26
+ }
27
+ };
28
+
29
+ if (savedSmolModel) {
30
+ const parsed = parseModelString(savedSmolModel);
31
+ if (parsed) {
32
+ const match = availableModels.find((model) => model.provider === parsed.provider && model.id === parsed.id);
33
+ addCandidate(match);
34
+ }
35
+ }
36
+
37
+ for (const pattern of SMOL_MODEL_PRIORITY) {
38
+ const needle = pattern.toLowerCase();
39
+ const exactMatch = availableModels.find((model) => model.id.toLowerCase() === needle);
40
+ addCandidate(exactMatch);
41
+
42
+ const fuzzyMatch = availableModels.find((model) => model.id.toLowerCase().includes(needle));
43
+ addCandidate(fuzzyMatch);
44
+ }
45
+
46
+ for (const model of availableModels) {
47
+ addCandidate(model);
48
+ }
49
+
50
+ return candidates;
51
+ }
52
+
16
53
  /**
17
54
  * Find the best available model for title generation.
18
55
  * Uses the configured smol model if set, otherwise auto-discovers using priority chain.
@@ -20,9 +57,9 @@ const MAX_INPUT_CHARS = 2000;
20
57
  * @param registry Model registry
21
58
  * @param savedSmolModel Optional saved smol model from settings (provider/modelId format)
22
59
  */
23
- export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: string): Promise<Model<any> | null> {
24
- const model = await findSmolModel(registry, savedSmolModel);
25
- return model ?? null;
60
+ export async function findTitleModel(registry: ModelRegistry, savedSmolModel?: string): Promise<Model<Api> | null> {
61
+ const candidates = getTitleModelCandidates(registry, savedSmolModel);
62
+ return candidates[0] ?? null;
26
63
  }
27
64
 
28
65
  /**
@@ -37,68 +74,85 @@ export async function generateSessionTitle(
37
74
  registry: ModelRegistry,
38
75
  savedSmolModel?: string,
39
76
  ): Promise<string | null> {
40
- const model = await findTitleModel(registry, savedSmolModel);
41
- if (!model) {
77
+ const candidates = getTitleModelCandidates(registry, savedSmolModel);
78
+ if (candidates.length === 0) {
42
79
  logger.debug("title-generator: no smol model found");
43
80
  return null;
44
81
  }
45
82
 
46
- const apiKey = await registry.getApiKey(model);
47
- if (!apiKey) {
48
- logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id });
49
- return null;
50
- }
51
-
52
83
  // Truncate message if too long
53
84
  const truncatedMessage =
54
85
  firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}...` : firstMessage;
86
+ const userMessage = `<user-message>\n${truncatedMessage}\n</user-message>`;
55
87
 
56
- const request = {
57
- model: `${model.provider}/${model.id}`,
58
- systemPrompt: TITLE_SYSTEM_PROMPT,
59
- userMessage: `<user-message>\n${truncatedMessage}\n</user-message>`,
60
- maxTokens: 30,
61
- };
62
- logger.debug("title-generator: request", request);
63
-
64
- try {
65
- const response = await completeSimple(
66
- model,
67
- {
68
- systemPrompt: request.systemPrompt,
69
- messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
70
- },
71
- {
72
- apiKey,
73
- maxTokens: 30,
74
- },
75
- );
76
-
77
- // Extract title from response text content
78
- let title = "";
79
- for (const content of response.content) {
80
- if (content.type === "text") {
81
- title += content.text;
82
- }
88
+ for (const model of candidates) {
89
+ const apiKey = await registry.getApiKey(model);
90
+ if (!apiKey) {
91
+ logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id });
92
+ continue;
83
93
  }
84
- title = title.trim();
85
94
 
86
- logger.debug("title-generator: response", {
87
- title,
88
- usage: response.usage,
89
- stopReason: response.stopReason,
90
- });
95
+ const request = {
96
+ model: `${model.provider}/${model.id}`,
97
+ systemPrompt: TITLE_SYSTEM_PROMPT,
98
+ userMessage,
99
+ maxTokens: 30,
100
+ };
101
+ logger.debug("title-generator: request", request);
91
102
 
92
- if (!title) {
93
- return null;
94
- }
103
+ try {
104
+ const response = await completeSimple(
105
+ model,
106
+ {
107
+ systemPrompt: request.systemPrompt,
108
+ messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
109
+ },
110
+ {
111
+ apiKey,
112
+ maxTokens: 30,
113
+ },
114
+ );
95
115
 
96
- // Clean up: remove quotes, trailing punctuation
97
- return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
98
- } catch (err) {
99
- logger.debug("title-generator: error", { error: err instanceof Error ? err.message : String(err) });
100
- return null;
116
+ if (response.stopReason === "error") {
117
+ logger.debug("title-generator: response error", {
118
+ model: request.model,
119
+ stopReason: response.stopReason,
120
+ errorMessage: response.errorMessage,
121
+ });
122
+ continue;
123
+ }
124
+
125
+ // Extract title from response text content
126
+ let title = "";
127
+ for (const content of response.content) {
128
+ if (content.type === "text") {
129
+ title += content.text;
130
+ }
131
+ }
132
+ title = title.trim();
133
+
134
+ logger.debug("title-generator: response", {
135
+ model: request.model,
136
+ title,
137
+ usage: response.usage,
138
+ stopReason: response.stopReason,
139
+ });
140
+
141
+ if (!title) {
142
+ continue;
143
+ }
144
+
145
+ // Clean up: remove quotes, trailing punctuation
146
+ return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
147
+ } catch (err) {
148
+ logger.debug("title-generator: error", {
149
+ model: request.model,
150
+ error: err instanceof Error ? err.message : String(err),
151
+ });
152
+ }
101
153
  }
154
+
155
+ return null;
102
156
  }
103
157
 
104
158
  /**
@@ -162,6 +162,7 @@ describe("createTools", () => {
162
162
  const expectedTools = [
163
163
  "ask",
164
164
  "bash",
165
+ "ssh",
165
166
  "edit",
166
167
  "find",
167
168
  "git",
@@ -26,6 +26,7 @@ export { createOutputTool, type OutputToolDetails } from "./output";
26
26
  export { createReadTool, type ReadToolDetails } from "./read";
27
27
  export { reportFindingTool, type SubmitReviewDetails } from "./review";
28
28
  export { filterRulebookRules, formatRulesForPrompt, type RulebookToolDetails } from "./rulebook";
29
+ export { createSshTool, type SSHToolDetails } from "./ssh";
29
30
  export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index";
30
31
  export type { TruncationResult } from "./truncate";
31
32
  export { createWebFetchTool, type WebFetchToolDetails } from "./web-fetch";
@@ -68,6 +69,7 @@ import { createOutputTool } from "./output";
68
69
  import { createReadTool } from "./read";
69
70
  import { reportFindingTool } from "./review";
70
71
  import { createRulebookTool } from "./rulebook";
72
+ import { createSshTool } from "./ssh";
71
73
  import { createTaskTool } from "./task/index";
72
74
  import { createWebFetchTool } from "./web-fetch";
73
75
  import { createWebSearchTool } from "./web-search/index";
@@ -115,6 +117,7 @@ type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
115
117
  export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
116
118
  ask: createAskTool,
117
119
  bash: createBashTool,
120
+ ssh: createSshTool,
118
121
  edit: createEditTool,
119
122
  find: createFindTool,
120
123
  git: createGitTool,
@@ -6,8 +6,8 @@
6
6
 
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import type { TextContent } from "@mariozechner/pi-ai";
10
9
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
10
+ import type { TextContent } from "@oh-my-pi/pi-ai";
11
11
  import type { Component } from "@oh-my-pi/pi-tui";
12
12
  import { Text } from "@oh-my-pi/pi-tui";
13
13
  import { Type } from "@sinclair/typebox";
@@ -189,11 +189,46 @@ function parseOutputProvenance(id: string): OutputProvenance | undefined {
189
189
  function extractPreviewLines(content: string, maxLines: number): string[] {
190
190
  const lines = content.split("\n");
191
191
  const preview: string[] = [];
192
+ const structuralTokens = new Set(["{", "}", "[", "]"]);
193
+
194
+ const isStructuralLine = (line: string): boolean => {
195
+ const trimmed = line.trim();
196
+ if (!trimmed) return true;
197
+ const cleaned = trimmed.replace(/,+$/, "");
198
+ return structuralTokens.has(cleaned);
199
+ };
200
+
201
+ const trimmedContent = content.trim();
202
+ const firstMeaningful = lines.find((line) => line.trim());
203
+ if (
204
+ firstMeaningful &&
205
+ isStructuralLine(firstMeaningful) &&
206
+ (trimmedContent.startsWith("{") || trimmedContent.startsWith("[")) &&
207
+ trimmedContent.length <= 200_000
208
+ ) {
209
+ try {
210
+ const parsed = JSON.parse(trimmedContent);
211
+ const minified = JSON.stringify(parsed);
212
+ if (minified) return [minified];
213
+ } catch {
214
+ // Fall back to line-based previews.
215
+ }
216
+ }
217
+
192
218
  for (const line of lines) {
193
- if (!line.trim()) continue;
219
+ if (isStructuralLine(line)) continue;
194
220
  preview.push(line);
195
221
  if (preview.length >= maxLines) break;
196
222
  }
223
+
224
+ if (preview.length === 0) {
225
+ for (const line of lines) {
226
+ if (!line.trim()) continue;
227
+ preview.push(line);
228
+ if (preview.length >= maxLines) break;
229
+ }
230
+ }
231
+
197
232
  return preview;
198
233
  }
199
234
 
@@ -1,9 +1,11 @@
1
+ import { homedir } from "node:os";
1
2
  import path from "node:path";
2
- import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
3
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
+ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
4
5
  import type { Component } from "@oh-my-pi/pi-tui";
5
6
  import { Text } from "@oh-my-pi/pi-tui";
6
7
  import { Type } from "@sinclair/typebox";
8
+ import { CONFIG_DIR_NAME } from "../../config";
7
9
  import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme";
8
10
  import readDescription from "../../prompts/tools/read.md" with { type: "text" };
9
11
  import { formatDimensionNote, resizeImage } from "../../utils/image-resize";
@@ -27,6 +29,13 @@ import {
27
29
  // Document types convertible via markitdown
28
30
  const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
29
31
 
32
+ // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
33
+ const REMOTE_MOUNT_PREFIX = path.join(homedir(), CONFIG_DIR_NAME, "remote") + path.sep;
34
+
35
+ function isRemoteMountPath(absolutePath: string): boolean {
36
+ return absolutePath.startsWith(REMOTE_MOUNT_PREFIX);
37
+ }
38
+
30
39
  // Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization
31
40
  const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
32
41
  const MAX_FUZZY_RESULTS = 5;
@@ -438,19 +447,23 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
438
447
  isDirectory = stat.isDirectory();
439
448
  } catch (error) {
440
449
  if (isNotFoundError(error)) {
441
- const suggestions = await findReadPathSuggestions(readPath, session.cwd, signal);
442
450
  let message = `File not found: ${readPath}`;
443
451
 
444
- if (suggestions?.suggestions.length) {
445
- const scopeLabel = suggestions.scopeLabel ? ` in ${suggestions.scopeLabel}` : "";
446
- message += `\n\nClosest matches${scopeLabel}:\n${suggestions.suggestions.map((match) => `- ${match}`).join("\n")}`;
447
- if (suggestions.truncated) {
448
- message += `\n[Search truncated to first ${MAX_FUZZY_CANDIDATES} paths. Refine the path if the match isn't listed.]`;
452
+ // Skip fuzzy matching for remote mounts (sshfs) to avoid hangs
453
+ if (!isRemoteMountPath(absolutePath)) {
454
+ const suggestions = await findReadPathSuggestions(readPath, session.cwd, signal);
455
+
456
+ if (suggestions?.suggestions.length) {
457
+ const scopeLabel = suggestions.scopeLabel ? ` in ${suggestions.scopeLabel}` : "";
458
+ message += `\n\nClosest matches${scopeLabel}:\n${suggestions.suggestions.map((match) => `- ${match}`).join("\n")}`;
459
+ if (suggestions.truncated) {
460
+ message += `\n[Search truncated to first ${MAX_FUZZY_CANDIDATES} paths. Refine the path if the match isn't listed.]`;
461
+ }
462
+ } else if (suggestions?.error) {
463
+ message += `\n\nFuzzy match failed: ${suggestions.error}`;
464
+ } else if (suggestions?.scopeLabel) {
465
+ message += `\n\nNo similar paths found in ${suggestions.scopeLabel}.`;
449
466
  }
450
- } else if (suggestions?.error) {
451
- message += `\n\nFuzzy match failed: ${suggestions.error}`;
452
- } else if (suggestions?.scopeLabel) {
453
- message += `\n\nNo similar paths found in ${suggestions.scopeLabel}.`;
454
467
  }
455
468
 
456
469
  throw new Error(message);
@@ -17,6 +17,7 @@ import { lspToolRenderer } from "./lsp/render";
17
17
  import { notebookToolRenderer } from "./notebook";
18
18
  import { outputToolRenderer } from "./output";
19
19
  import { readToolRenderer } from "./read";
20
+ import { sshToolRenderer } from "./ssh";
20
21
  import { taskToolRenderer } from "./task/render";
21
22
  import { webFetchToolRenderer } from "./web-fetch";
22
23
  import { webSearchToolRenderer } from "./web-search/render";
@@ -43,6 +44,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
43
44
  notebook: notebookToolRenderer as ToolRenderer,
44
45
  output: outputToolRenderer as ToolRenderer,
45
46
  read: readToolRenderer as ToolRenderer,
47
+ ssh: sshToolRenderer as ToolRenderer,
46
48
  task: taskToolRenderer as ToolRenderer,
47
49
  web_fetch: webFetchToolRenderer as ToolRenderer,
48
50
  web_search: webSearchToolRenderer as ToolRenderer,
@@ -0,0 +1,302 @@
1
+ import type { AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
2
+ import type { Component } from "@oh-my-pi/pi-tui";
3
+ import { Text } from "@oh-my-pi/pi-tui";
4
+ import { Type } from "@sinclair/typebox";
5
+ import type { SSHHost } from "../../capability/ssh";
6
+ import { sshCapability } from "../../capability/ssh";
7
+ import { loadSync } from "../../discovery/index";
8
+ import type { Theme } from "../../modes/interactive/theme/theme";
9
+ import sshDescriptionBase from "../../prompts/tools/ssh.md" with { type: "text" };
10
+ import type { RenderResultOptions } from "../custom-tools/types";
11
+ import type { SSHHostInfo } from "../ssh/connection-manager";
12
+ import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager";
13
+ import { executeSSH } from "../ssh/ssh-executor";
14
+ import type { ToolSession } from "./index";
15
+ import { createToolUIKit } from "./render-utils";
16
+ import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
17
+
18
+ const sshSchema = Type.Object({
19
+ host: Type.String({ description: "Host name from ssh.json or .ssh.json" }),
20
+ command: Type.String({ description: "Command to execute on the remote host" }),
21
+ cwd: Type.Optional(Type.String({ description: "Remote working directory (optional)" })),
22
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
23
+ });
24
+
25
+ export interface SSHToolDetails {
26
+ truncation?: TruncationResult;
27
+ fullOutputPath?: string;
28
+ }
29
+
30
+ function formatHostEntry(host: SSHHost): string {
31
+ const info = getHostInfoForHost(host);
32
+
33
+ let shell: string;
34
+ if (!info) {
35
+ shell = "detecting...";
36
+ } else if (info.os === "windows") {
37
+ if (info.compatEnabled) {
38
+ const compatShell = info.compatShell || "bash";
39
+ shell = `windows/${compatShell}`;
40
+ } else if (info.shell === "powershell") {
41
+ shell = "windows/powershell";
42
+ } else {
43
+ shell = "windows/cmd";
44
+ }
45
+ } else if (info.os === "linux") {
46
+ shell = `linux/${info.shell}`;
47
+ } else if (info.os === "macos") {
48
+ shell = `macos/${info.shell}`;
49
+ } else {
50
+ shell = `unknown/${info.shell}`;
51
+ }
52
+
53
+ return `- ${host.name} (${host.host}) | ${shell}`;
54
+ }
55
+
56
+ function formatDescription(hosts: SSHHost[]): string {
57
+ if (hosts.length === 0) {
58
+ return sshDescriptionBase;
59
+ }
60
+ const hostList = hosts.map(formatHostEntry).join("\n");
61
+ return `${sshDescriptionBase}\n\nAvailable hosts:\n${hostList}`;
62
+ }
63
+
64
+ function quoteRemotePath(value: string): string {
65
+ if (value.length === 0) {
66
+ return "''";
67
+ }
68
+ const escaped = value.replace(/'/g, "'\\''");
69
+ return `'${escaped}'`;
70
+ }
71
+
72
+ function quotePowerShellPath(value: string): string {
73
+ if (value.length === 0) {
74
+ return "''";
75
+ }
76
+ const escaped = value.replace(/'/g, "''");
77
+ return `'${escaped}'`;
78
+ }
79
+
80
+ function quoteCmdPath(value: string): string {
81
+ const escaped = value.replace(/"/g, '""');
82
+ return `"${escaped}"`;
83
+ }
84
+
85
+ function buildRemoteCommand(command: string, cwd: string | undefined, info: SSHHostInfo): string {
86
+ if (!cwd) return command;
87
+
88
+ if (info.os === "windows" && !info.compatEnabled) {
89
+ if (info.shell === "powershell") {
90
+ return `Set-Location -Path ${quotePowerShellPath(cwd)}; ${command}`;
91
+ }
92
+ return `cd /d ${quoteCmdPath(cwd)} && ${command}`;
93
+ }
94
+
95
+ return `cd -- ${quoteRemotePath(cwd)} && ${command}`;
96
+ }
97
+
98
+ function loadHosts(session: ToolSession): {
99
+ hostNames: string[];
100
+ hostsByName: Map<string, SSHHost>;
101
+ } {
102
+ const result = loadSync<SSHHost>(sshCapability.id, { cwd: session.cwd });
103
+ const hostsByName = new Map<string, SSHHost>();
104
+ for (const host of result.items) {
105
+ if (!hostsByName.has(host.name)) {
106
+ hostsByName.set(host.name, host);
107
+ }
108
+ }
109
+ const hostNames = Array.from(hostsByName.keys()).sort();
110
+ return { hostNames, hostsByName };
111
+ }
112
+
113
+ export function createSshTool(session: ToolSession): AgentTool<typeof sshSchema> | null {
114
+ const { hostNames, hostsByName } = loadHosts(session);
115
+ if (hostNames.length === 0) {
116
+ return null;
117
+ }
118
+
119
+ const allowedHosts = new Set(hostNames);
120
+
121
+ const descriptionHosts = hostNames
122
+ .map((name) => hostsByName.get(name))
123
+ .filter((host): host is SSHHost => host !== undefined);
124
+
125
+ return {
126
+ name: "ssh",
127
+ label: "SSH",
128
+ description: formatDescription(descriptionHosts),
129
+ parameters: sshSchema,
130
+ execute: async (
131
+ _toolCallId: string,
132
+ { host, command, cwd, timeout }: { host: string; command: string; cwd?: string; timeout?: number },
133
+ signal?: AbortSignal,
134
+ onUpdate?,
135
+ _ctx?: AgentToolContext,
136
+ ) => {
137
+ if (!allowedHosts.has(host)) {
138
+ throw new Error(`Unknown SSH host: ${host}. Available hosts: ${hostNames.join(", ")}`);
139
+ }
140
+
141
+ const hostConfig = hostsByName.get(host);
142
+ if (!hostConfig) {
143
+ throw new Error(`SSH host not loaded: ${host}`);
144
+ }
145
+
146
+ const hostInfo = await ensureHostInfo(hostConfig);
147
+ const remoteCommand = buildRemoteCommand(command, cwd, hostInfo);
148
+ let currentOutput = "";
149
+
150
+ const result = await executeSSH(hostConfig, remoteCommand, {
151
+ timeout: timeout ? timeout * 1000 : undefined,
152
+ signal,
153
+ compatEnabled: hostInfo.compatEnabled,
154
+ onChunk: (chunk) => {
155
+ currentOutput += chunk;
156
+ if (onUpdate) {
157
+ const truncation = truncateTail(currentOutput);
158
+ onUpdate({
159
+ content: [{ type: "text", text: truncation.content || "" }],
160
+ details: {
161
+ truncation: truncation.truncated ? truncation : undefined,
162
+ },
163
+ });
164
+ }
165
+ },
166
+ });
167
+
168
+ if (result.cancelled) {
169
+ throw new Error(result.output || "Command aborted");
170
+ }
171
+
172
+ const truncation = truncateTail(result.output);
173
+ let outputText = truncation.content || "(no output)";
174
+
175
+ let details: SSHToolDetails | undefined;
176
+
177
+ if (truncation.truncated) {
178
+ details = {
179
+ truncation,
180
+ fullOutputPath: result.fullOutputPath,
181
+ };
182
+
183
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
184
+ const endLine = truncation.totalLines;
185
+
186
+ if (truncation.lastLinePartial) {
187
+ const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
188
+ outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`;
189
+ } else if (truncation.truncatedBy === "lines") {
190
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`;
191
+ } else {
192
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
193
+ }
194
+ }
195
+
196
+ if (result.exitCode !== 0 && result.exitCode !== undefined) {
197
+ outputText += `\n\nCommand exited with code ${result.exitCode}`;
198
+ throw new Error(outputText);
199
+ }
200
+
201
+ return { content: [{ type: "text", text: outputText }], details };
202
+ },
203
+ };
204
+ }
205
+
206
+ // =============================================================================
207
+ // TUI Renderer
208
+ // =============================================================================
209
+
210
+ interface SshRenderArgs {
211
+ host?: string;
212
+ command?: string;
213
+ timeout?: number;
214
+ }
215
+
216
+ interface SshRenderContext {
217
+ /** Visual lines for truncated output (pre-computed by tool-execution) */
218
+ visualLines?: string[];
219
+ /** Number of lines skipped */
220
+ skippedCount?: number;
221
+ /** Total visual lines */
222
+ totalVisualLines?: number;
223
+ }
224
+
225
+ export const sshToolRenderer = {
226
+ renderCall(args: SshRenderArgs, uiTheme: Theme): Component {
227
+ const ui = createToolUIKit(uiTheme);
228
+ const host = args.host || uiTheme.format.ellipsis;
229
+ const command = args.command || uiTheme.format.ellipsis;
230
+ const text = ui.title(`[${host}] $ ${command}`);
231
+ return new Text(text, 0, 0);
232
+ },
233
+
234
+ renderResult(
235
+ result: {
236
+ content: Array<{ type: string; text?: string }>;
237
+ details?: SSHToolDetails;
238
+ },
239
+ options: RenderResultOptions & { renderContext?: SshRenderContext },
240
+ uiTheme: Theme,
241
+ ): Component {
242
+ const ui = createToolUIKit(uiTheme);
243
+ const { expanded, renderContext } = options;
244
+ const details = result.details;
245
+ const lines: string[] = [];
246
+
247
+ const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
248
+ const output = textContent.trim();
249
+
250
+ if (output) {
251
+ if (expanded) {
252
+ const styledOutput = output
253
+ .split("\n")
254
+ .map((line) => uiTheme.fg("toolOutput", line))
255
+ .join("\n");
256
+ lines.push(styledOutput);
257
+ } else if (renderContext?.visualLines) {
258
+ const { visualLines, skippedCount = 0, totalVisualLines = visualLines.length } = renderContext;
259
+ if (skippedCount > 0) {
260
+ lines.push(
261
+ uiTheme.fg(
262
+ "dim",
263
+ `${uiTheme.format.ellipsis} (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`,
264
+ ),
265
+ );
266
+ }
267
+ lines.push(...visualLines);
268
+ } else {
269
+ const outputLines = output.split("\n");
270
+ const maxLines = 5;
271
+ const displayLines = outputLines.slice(0, maxLines);
272
+ const remaining = outputLines.length - maxLines;
273
+
274
+ lines.push(...displayLines.map((line) => uiTheme.fg("toolOutput", line)));
275
+ if (remaining > 0) {
276
+ lines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${remaining} more lines) (ctrl+o to expand)`));
277
+ }
278
+ }
279
+ }
280
+
281
+ const truncation = details?.truncation;
282
+ const fullOutputPath = details?.fullOutputPath;
283
+ if (truncation?.truncated || fullOutputPath) {
284
+ const warnings: string[] = [];
285
+ if (fullOutputPath) {
286
+ warnings.push(`Full output: ${fullOutputPath}`);
287
+ }
288
+ if (truncation?.truncated) {
289
+ if (truncation.truncatedBy === "lines") {
290
+ warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
291
+ } else {
292
+ warnings.push(
293
+ `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
294
+ );
295
+ }
296
+ }
297
+ lines.push(uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". "))));
298
+ }
299
+
300
+ return new Text(lines.join("\n"), 0, 0);
301
+ },
302
+ };
@@ -13,8 +13,8 @@
13
13
  * - Session artifacts for debugging
14
14
  */
15
15
 
16
- import type { Usage } from "@mariozechner/pi-ai";
17
16
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
17
+ import type { Usage } from "@oh-my-pi/pi-ai";
18
18
  import type { Theme } from "../../../modes/interactive/theme/theme";
19
19
  import taskDescriptionTemplate from "../../../prompts/tools/task.md" with { type: "text" };
20
20
  import { formatDuration } from "../render-utils";