@oh-my-pi/pi-coding-agent 3.33.0 → 3.35.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 (72) hide show
  1. package/CHANGELOG.md +57 -8
  2. package/docs/custom-tools.md +1 -1
  3. package/docs/extensions.md +4 -4
  4. package/docs/hooks.md +2 -2
  5. package/docs/sdk.md +4 -8
  6. package/examples/custom-tools/README.md +2 -2
  7. package/examples/extensions/README.md +1 -1
  8. package/examples/extensions/todo.ts +1 -1
  9. package/examples/hooks/custom-compaction.ts +4 -2
  10. package/examples/hooks/handoff.ts +1 -1
  11. package/examples/hooks/qna.ts +1 -1
  12. package/examples/sdk/02-custom-model.ts +1 -1
  13. package/examples/sdk/README.md +1 -1
  14. package/package.json +5 -5
  15. package/src/capability/ssh.ts +42 -0
  16. package/src/cli/file-processor.ts +1 -1
  17. package/src/cli/list-models.ts +1 -1
  18. package/src/core/agent-session.ts +214 -31
  19. package/src/core/auth-storage.ts +1 -1
  20. package/src/core/compaction/branch-summarization.ts +2 -2
  21. package/src/core/compaction/compaction.ts +2 -2
  22. package/src/core/compaction/utils.ts +1 -1
  23. package/src/core/custom-tools/types.ts +1 -1
  24. package/src/core/extensions/runner.ts +1 -1
  25. package/src/core/extensions/types.ts +1 -1
  26. package/src/core/extensions/wrapper.ts +1 -1
  27. package/src/core/hooks/runner.ts +2 -2
  28. package/src/core/hooks/types.ts +1 -1
  29. package/src/core/index.ts +11 -0
  30. package/src/core/messages.ts +1 -1
  31. package/src/core/model-registry.ts +1 -1
  32. package/src/core/model-resolver.ts +7 -6
  33. package/src/core/sdk.ts +33 -4
  34. package/src/core/session-manager.ts +16 -1
  35. package/src/core/settings-manager.ts +20 -6
  36. package/src/core/ssh/connection-manager.ts +466 -0
  37. package/src/core/ssh/ssh-executor.ts +190 -0
  38. package/src/core/ssh/sshfs-mount.ts +162 -0
  39. package/src/core/ssh-executor.ts +5 -0
  40. package/src/core/system-prompt.ts +424 -1
  41. package/src/core/title-generator.ts +2 -2
  42. package/src/core/tools/edit.ts +1 -0
  43. package/src/core/tools/grep.ts +1 -1
  44. package/src/core/tools/index.test.ts +1 -0
  45. package/src/core/tools/index.ts +5 -0
  46. package/src/core/tools/output.ts +1 -1
  47. package/src/core/tools/read.ts +24 -11
  48. package/src/core/tools/renderers.ts +3 -0
  49. package/src/core/tools/ssh.ts +302 -0
  50. package/src/core/tools/task/index.ts +11 -2
  51. package/src/core/tools/task/model-resolver.ts +5 -4
  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 +4 -1
  58. package/src/modes/interactive/components/assistant-message.ts +1 -1
  59. package/src/modes/interactive/components/custom-message.ts +1 -1
  60. package/src/modes/interactive/components/footer.ts +1 -1
  61. package/src/modes/interactive/components/hook-message.ts +1 -1
  62. package/src/modes/interactive/components/model-selector.ts +1 -1
  63. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  64. package/src/modes/interactive/components/status-line.ts +1 -1
  65. package/src/modes/interactive/components/tool-execution.ts +15 -12
  66. package/src/modes/interactive/interactive-mode.ts +43 -9
  67. package/src/modes/print-mode.ts +1 -1
  68. package/src/modes/rpc/rpc-client.ts +1 -1
  69. package/src/modes/rpc/rpc-types.ts +1 -1
  70. package/src/prompts/system-prompt.md +4 -0
  71. package/src/prompts/tools/ssh.md +74 -0
  72. package/src/utils/image-resize.ts +1 -1
@@ -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";
@@ -135,7 +135,12 @@ export async function createTaskTool(
135
135
  const startTime = Date.now();
136
136
  const { agents, projectAgentsDir } = await discoverAgents(session.cwd);
137
137
  const { agent: agentName, context, model, output: outputSchema } = params;
138
- const modelOverride = model ?? session.getModelString?.();
138
+
139
+ const isDefaultModelAlias = (value: string | undefined): boolean => {
140
+ if (!value) return true;
141
+ const normalized = value.trim().toLowerCase();
142
+ return normalized === "default" || normalized === "pi/default" || normalized === "omp/default";
143
+ };
139
144
 
140
145
  // Validate agent exists
141
146
  const agent = getAgent(agents, agentName);
@@ -156,6 +161,10 @@ export async function createTaskTool(
156
161
  };
157
162
  }
158
163
 
164
+ const shouldInheritSessionModel = model === undefined && isDefaultModelAlias(agent.model);
165
+ const sessionModel = shouldInheritSessionModel ? session.getActiveModelString?.() : undefined;
166
+ const modelOverride = model ?? sessionModel ?? session.getModelString?.();
167
+
159
168
  // Handle empty or missing tasks
160
169
  if (!params.tasks || params.tasks.length === 0) {
161
170
  return {
@@ -8,7 +8,7 @@
8
8
  * - Fuzzy match: "opus" → "p-anthropic/claude-opus-4-5"
9
9
  * - Comma fallback: "gpt, opus" → tries gpt first, then opus
10
10
  * - "default" → undefined (use system default)
11
- * - "omp/slow" → configured slow model from settings
11
+ * - "omp/slow" or "pi/slow" → configured slow model from settings
12
12
  */
13
13
 
14
14
  import { type Settings, settingsCapability } from "../../../capability/settings";
@@ -145,9 +145,10 @@ export function resolveModelPattern(pattern: string | undefined, availableModels
145
145
  .filter(Boolean);
146
146
 
147
147
  for (const p of patterns) {
148
- // Handle omp/<role> aliases - looks up role in settings.modelRoles
149
- if (p.toLowerCase().startsWith("omp/")) {
150
- const role = p.slice(4); // Remove "omp/" prefix
148
+ // Handle omp/<role> or pi/<role> aliases - looks up role in settings.modelRoles
149
+ const lower = p.toLowerCase();
150
+ if (lower.startsWith("omp/") || lower.startsWith("pi/")) {
151
+ const role = lower.startsWith("omp/") ? p.slice(4) : p.slice(3);
151
152
  const resolved = resolveOmpAlias(role, models);
152
153
  if (resolved) return resolved;
153
154
  continue; // Role not configured, try next pattern
@@ -1,4 +1,4 @@
1
- import type { Usage } from "@mariozechner/pi-ai";
1
+ import type { Usage } from "@oh-my-pi/pi-ai";
2
2
  import { type Static, Type } from "@sinclair/typebox";
3
3
 
4
4
  /** Source of an agent definition */
@@ -13,8 +13,8 @@
13
13
  * 5. Parent can send { type: "abort" } to request cancellation
14
14
  */
15
15
 
16
- import type { Api, Model } from "@mariozechner/pi-ai";
17
16
  import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
+ import type { Api, Model } from "@oh-my-pi/pi-ai";
18
18
  import type { AgentSessionEvent } from "../../agent-session";
19
19
  import { parseModelPattern, parseModelString } from "../../model-resolver";
20
20
  import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk";
package/src/core/voice.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { unlinkSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { completeSimple, type Model } from "@mariozechner/pi-ai";
4
+ import { completeSimple, type Model } from "@oh-my-pi/pi-ai";
5
5
  import { nanoid } from "nanoid";
6
6
  import voiceSummaryPrompt from "../prompts/voice-summary.md" with { type: "text" };
7
7
  import { logger } from "./logger";
@@ -18,6 +18,7 @@ import "../capability/settings";
18
18
  import "../capability/skill";
19
19
  import "../capability/slash-command";
20
20
  import "../capability/system-prompt";
21
+ import "../capability/ssh";
21
22
  import "../capability/tool";
22
23
 
23
24
  // Import providers (each registers itself on import)
@@ -32,6 +33,7 @@ import "./github";
32
33
  import "./vscode";
33
34
  import "./agents-md";
34
35
  import "./mcp-json";
36
+ import "./ssh";
35
37
 
36
38
  export type { ContextFile } from "../capability/context-file";
37
39
  export type { Extension, ExtensionManifest } from "../capability/extension";
@@ -70,6 +72,7 @@ export type { Rule, RuleFrontmatter } from "../capability/rule";
70
72
  export type { Settings } from "../capability/settings";
71
73
  export type { Skill, SkillFrontmatter } from "../capability/skill";
72
74
  export type { SlashCommand } from "../capability/slash-command";
75
+ export type { SSHHost } from "../capability/ssh";
73
76
  export type { SystemPrompt } from "../capability/system-prompt";
74
77
  export type { CustomTool } from "../capability/tool";
75
78
  // Re-export types
@@ -0,0 +1,162 @@
1
+ /**
2
+ * SSH JSON Provider
3
+ *
4
+ * Discovers SSH hosts from ssh.json or .ssh.json in the project root.
5
+ * Priority: 5 (low, project-level only)
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { registerProvider } from "../capability/index";
10
+ import { type SSHHost, sshCapability } from "../capability/ssh";
11
+ import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
12
+ import { createSourceMeta, expandEnvVarsDeep, parseJSON } from "./helpers";
13
+
14
+ const PROVIDER_ID = "ssh-json";
15
+ const DISPLAY_NAME = "SSH Config";
16
+
17
+ interface SSHConfigFile {
18
+ hosts?: Record<
19
+ string,
20
+ {
21
+ host?: string;
22
+ username?: string;
23
+ port?: number | string;
24
+ compat?: boolean | string;
25
+ key?: string;
26
+ keyPath?: string;
27
+ description?: string;
28
+ }
29
+ >;
30
+ }
31
+
32
+ function expandTilde(value: string, home: string): string {
33
+ if (value === "~") return home;
34
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
35
+ return `${home}${value.slice(1)}`;
36
+ }
37
+ return value;
38
+ }
39
+
40
+ function parsePort(value: number | string | undefined): number | undefined {
41
+ if (value === undefined) return undefined;
42
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
43
+ const parsed = Number.parseInt(value, 10);
44
+ return Number.isNaN(parsed) ? undefined : parsed;
45
+ }
46
+
47
+ function parseCompat(value: boolean | string | undefined): boolean | undefined {
48
+ if (value === undefined) return undefined;
49
+ if (typeof value === "boolean") return value;
50
+ const normalized = value.trim().toLowerCase();
51
+ if (normalized === "true" || normalized === "1" || normalized === "yes") return true;
52
+ if (normalized === "false" || normalized === "0" || normalized === "no") return false;
53
+ return undefined;
54
+ }
55
+
56
+ function normalizeHost(
57
+ name: string,
58
+ raw: NonNullable<SSHConfigFile["hosts"]>[string],
59
+ source: SourceMeta,
60
+ home: string,
61
+ warnings: string[],
62
+ ): SSHHost | null {
63
+ if (!raw.host) {
64
+ warnings.push(`Missing host for SSH entry: ${name}`);
65
+ return null;
66
+ }
67
+
68
+ const port = parsePort(raw.port);
69
+ if (raw.port !== undefined && port === undefined) {
70
+ warnings.push(`Invalid port for SSH entry ${name}: ${String(raw.port)}`);
71
+ }
72
+
73
+ const compat = parseCompat(raw.compat);
74
+ if (raw.compat !== undefined && compat === undefined) {
75
+ warnings.push(`Invalid compat flag for SSH entry ${name}: ${String(raw.compat)}`);
76
+ }
77
+
78
+ const keyValue = raw.keyPath ?? raw.key;
79
+ const keyPath = keyValue ? expandTilde(keyValue, home) : undefined;
80
+
81
+ return {
82
+ name,
83
+ host: raw.host,
84
+ username: raw.username,
85
+ port,
86
+ keyPath,
87
+ description: raw.description,
88
+ compat,
89
+ _source: source,
90
+ };
91
+ }
92
+
93
+ function loadSshJsonFile(ctx: LoadContext, path: string): LoadResult<SSHHost> {
94
+ const items: SSHHost[] = [];
95
+ const warnings: string[] = [];
96
+
97
+ if (!ctx.fs.isFile(path)) {
98
+ return { items, warnings };
99
+ }
100
+
101
+ const content = ctx.fs.readFile(path);
102
+ if (content === null) {
103
+ warnings.push(`Failed to read ${path}`);
104
+ return { items, warnings };
105
+ }
106
+
107
+ const parsed = parseJSON<SSHConfigFile>(content);
108
+ if (!parsed) {
109
+ warnings.push(`Failed to parse JSON in ${path}`);
110
+ return { items, warnings };
111
+ }
112
+
113
+ const config = expandEnvVarsDeep(parsed);
114
+ if (!config.hosts || typeof config.hosts !== "object") {
115
+ warnings.push(`Missing hosts in ${path}`);
116
+ return { items, warnings };
117
+ }
118
+
119
+ const source = createSourceMeta(PROVIDER_ID, path, "project");
120
+ for (const [name, rawHost] of Object.entries(config.hosts)) {
121
+ if (!name.trim()) {
122
+ warnings.push(`Invalid SSH host name in ${path}`);
123
+ continue;
124
+ }
125
+ if (!rawHost || typeof rawHost !== "object") {
126
+ warnings.push(`Invalid host entry in ${path}: ${name}`);
127
+ continue;
128
+ }
129
+ const host = normalizeHost(name, rawHost, source, ctx.home, warnings);
130
+ if (host) items.push(host);
131
+ }
132
+
133
+ return {
134
+ items,
135
+ warnings: warnings.length > 0 ? warnings : undefined,
136
+ };
137
+ }
138
+
139
+ function load(ctx: LoadContext): LoadResult<SSHHost> {
140
+ const allItems: SSHHost[] = [];
141
+ const allWarnings: string[] = [];
142
+
143
+ for (const filename of ["ssh.json", ".ssh.json"]) {
144
+ const path = join(ctx.cwd, filename);
145
+ const result = loadSshJsonFile(ctx, path);
146
+ allItems.push(...result.items);
147
+ if (result.warnings) allWarnings.push(...result.warnings);
148
+ }
149
+
150
+ return {
151
+ items: allItems,
152
+ warnings: allWarnings.length > 0 ? allWarnings : undefined,
153
+ };
154
+ }
155
+
156
+ registerProvider(sshCapability.id, {
157
+ id: PROVIDER_ID,
158
+ displayName: DISPLAY_NAME,
159
+ description: "Load SSH hosts from ssh.json or .ssh.json in the project root",
160
+ priority: 5,
161
+ load,
162
+ });
package/src/main.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { homedir, tmpdir } from "node:os";
9
9
  import { join, resolve } from "node:path";
10
- import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai";
10
+ import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai";
11
11
  import chalk from "chalk";
12
12
  import { type Args, parseArgs, printHelp } from "./cli/args";
13
13
  import { processFileArguments } from "./cli/file-processor";
@@ -289,6 +289,9 @@ async function buildSessionOptions(
289
289
  process.exit(1);
290
290
  }
291
291
  options.model = model;
292
+ settingsManager.applyOverrides({
293
+ modelRoles: { default: `${model.provider}/${model.id}` },
294
+ });
292
295
  } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
293
296
  options.model = scopedModels[0].model;
294
297
  }
@@ -1,4 +1,4 @@
1
- import type { AssistantMessage } from "@mariozechner/pi-ai";
1
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
2
  import { Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
3
3
  import { getMarkdownTheme, theme } from "../theme/theme";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { TextContent } from "@mariozechner/pi-ai";
1
+ import type { TextContent } from "@oh-my-pi/pi-ai";
2
2
  import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
4
4
  import type { MessageRenderer } from "../../../core/extensions/types";
@@ -1,5 +1,5 @@
1
1
  import { existsSync, type FSWatcher, readFileSync, watch } from "node:fs";
2
- import type { AssistantMessage } from "@mariozechner/pi-ai";
2
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
3
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
4
4
  import { dirname, join } from "path";
5
5
  import type { AgentSession } from "../../../core/agent-session";
@@ -1,4 +1,4 @@
1
- import type { TextContent } from "@mariozechner/pi-ai";
1
+ import type { TextContent } from "@oh-my-pi/pi-ai";
2
2
  import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
4
4
  import type { HookMessageRenderer } from "../../../core/hooks/types";
@@ -1,4 +1,4 @@
1
- import { type Model, modelsAreEqual } from "@mariozechner/pi-ai";
1
+ import { type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
2
2
  import {
3
3
  Container,
4
4
  Input,
@@ -1,4 +1,4 @@
1
- import { getOAuthProviders, type OAuthProviderInfo } from "@mariozechner/pi-ai";
1
+ import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai";
2
2
  import { Container, isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
3
3
  import type { AuthStorage } from "../../../core/auth-storage";
4
4
  import { theme } from "../theme/theme";
@@ -1,4 +1,4 @@
1
- import type { AssistantMessage } from "@mariozechner/pi-ai";
1
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
2
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
3
3
  import { type FSWatcher, watch } from "fs";
4
4
  import { dirname, join } from "path";
@@ -369,20 +369,23 @@ export class ToolExecutionComponent extends Container {
369
369
  this.contentBox.setBgFn(bgFn);
370
370
  this.contentBox.clear();
371
371
 
372
- // Render call component
373
- try {
374
- const callComponent = renderer.renderCall(this.args, theme);
375
- if (callComponent) {
376
- // Ensure component has invalidate() method for Component interface
377
- const component = callComponent as any;
378
- if (!component.invalidate) {
379
- component.invalidate = () => {};
372
+ const shouldRenderCall = !this.result || !renderer.mergeCallAndResult;
373
+ if (shouldRenderCall) {
374
+ // Render call component
375
+ try {
376
+ const callComponent = renderer.renderCall(this.args, theme);
377
+ if (callComponent) {
378
+ // Ensure component has invalidate() method for Component interface
379
+ const component = callComponent as any;
380
+ if (!component.invalidate) {
381
+ component.invalidate = () => {};
382
+ }
383
+ this.contentBox.addChild(component);
380
384
  }
381
- this.contentBox.addChild(component);
385
+ } catch {
386
+ // Fall back to default on error
387
+ this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0));
382
388
  }
383
- } catch {
384
- // Fall back to default on error
385
- this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0));
386
389
  }
387
390
 
388
391
  // Render result component if we have a result