@oh-my-pi/pi-coding-agent 6.1.0 → 6.7.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 (93) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/docs/sdk.md +1 -1
  3. package/package.json +5 -5
  4. package/scripts/generate-template.ts +6 -6
  5. package/src/cli/args.ts +3 -0
  6. package/src/core/agent-session.ts +39 -0
  7. package/src/core/bash-executor.ts +3 -3
  8. package/src/core/cursor/exec-bridge.ts +95 -88
  9. package/src/core/custom-commands/bundled/review/index.ts +142 -145
  10. package/src/core/custom-commands/bundled/wt/index.ts +68 -66
  11. package/src/core/custom-commands/loader.ts +4 -6
  12. package/src/core/custom-tools/index.ts +2 -2
  13. package/src/core/custom-tools/loader.ts +66 -61
  14. package/src/core/custom-tools/types.ts +4 -4
  15. package/src/core/custom-tools/wrapper.ts +61 -25
  16. package/src/core/event-bus.ts +19 -47
  17. package/src/core/extensions/index.ts +8 -4
  18. package/src/core/extensions/loader.ts +160 -120
  19. package/src/core/extensions/types.ts +4 -4
  20. package/src/core/extensions/wrapper.ts +149 -100
  21. package/src/core/hooks/index.ts +1 -1
  22. package/src/core/hooks/tool-wrapper.ts +96 -70
  23. package/src/core/hooks/types.ts +1 -2
  24. package/src/core/index.ts +1 -0
  25. package/src/core/mcp/index.ts +6 -2
  26. package/src/core/mcp/json-rpc.ts +88 -0
  27. package/src/core/mcp/loader.ts +22 -4
  28. package/src/core/mcp/manager.ts +202 -48
  29. package/src/core/mcp/tool-bridge.ts +143 -55
  30. package/src/core/mcp/tool-cache.ts +122 -0
  31. package/src/core/python-executor.ts +3 -9
  32. package/src/core/sdk.ts +33 -32
  33. package/src/core/session-manager.ts +30 -0
  34. package/src/core/settings-manager.ts +34 -1
  35. package/src/core/ssh/ssh-executor.ts +6 -84
  36. package/src/core/streaming-output.ts +107 -53
  37. package/src/core/tools/ask.ts +92 -93
  38. package/src/core/tools/bash.ts +103 -94
  39. package/src/core/tools/calculator.ts +41 -26
  40. package/src/core/tools/complete.ts +76 -66
  41. package/src/core/tools/context.ts +25 -25
  42. package/src/core/tools/exa/index.ts +1 -1
  43. package/src/core/tools/exa/mcp-client.ts +56 -101
  44. package/src/core/tools/find.ts +250 -253
  45. package/src/core/tools/git.ts +39 -33
  46. package/src/core/tools/grep.ts +440 -427
  47. package/src/core/tools/index.ts +62 -61
  48. package/src/core/tools/ls.ts +119 -114
  49. package/src/core/tools/lsp/clients/biome-client.ts +5 -7
  50. package/src/core/tools/lsp/clients/index.ts +4 -4
  51. package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
  52. package/src/core/tools/lsp/config.ts +2 -2
  53. package/src/core/tools/lsp/index.ts +824 -639
  54. package/src/core/tools/notebook.ts +121 -119
  55. package/src/core/tools/output.ts +163 -147
  56. package/src/core/tools/patch/applicator.ts +1100 -0
  57. package/src/core/tools/patch/diff.ts +362 -0
  58. package/src/core/tools/patch/fuzzy.ts +647 -0
  59. package/src/core/tools/patch/index.ts +430 -0
  60. package/src/core/tools/patch/normalize.ts +220 -0
  61. package/src/core/tools/patch/normative.ts +49 -0
  62. package/src/core/tools/patch/parser.ts +528 -0
  63. package/src/core/tools/patch/shared.ts +228 -0
  64. package/src/core/tools/patch/types.ts +244 -0
  65. package/src/core/tools/python.ts +139 -136
  66. package/src/core/tools/read.ts +237 -216
  67. package/src/core/tools/render-utils.ts +196 -77
  68. package/src/core/tools/renderers.ts +1 -1
  69. package/src/core/tools/ssh.ts +99 -80
  70. package/src/core/tools/task/executor.ts +11 -7
  71. package/src/core/tools/task/index.ts +352 -343
  72. package/src/core/tools/task/worker.ts +13 -23
  73. package/src/core/tools/todo-write.ts +74 -59
  74. package/src/core/tools/web-fetch.ts +54 -47
  75. package/src/core/tools/web-search/index.ts +27 -16
  76. package/src/core/tools/write.ts +89 -41
  77. package/src/core/ttsr.ts +106 -152
  78. package/src/core/voice.ts +49 -39
  79. package/src/index.ts +16 -12
  80. package/src/lib/worktree/index.ts +1 -9
  81. package/src/modes/interactive/components/diff.ts +15 -8
  82. package/src/modes/interactive/components/settings-defs.ts +24 -0
  83. package/src/modes/interactive/components/tool-execution.ts +34 -6
  84. package/src/modes/interactive/controllers/event-controller.ts +6 -19
  85. package/src/modes/interactive/controllers/input-controller.ts +1 -1
  86. package/src/modes/interactive/utils/ui-helpers.ts +5 -1
  87. package/src/modes/rpc/rpc-mode.ts +99 -81
  88. package/src/prompts/tools/patch.md +76 -0
  89. package/src/prompts/tools/read.md +1 -1
  90. package/src/prompts/tools/{edit.md → replace.md} +1 -0
  91. package/src/utils/shell.ts +0 -40
  92. package/src/core/tools/edit-diff.ts +0 -574
  93. package/src/core/tools/edit.ts +0 -326
@@ -231,67 +231,104 @@ export interface ToolUITitleOptions {
231
231
  bold?: boolean;
232
232
  }
233
233
 
234
- export interface ToolUIKit {
235
- theme: Theme;
236
- title: (label: string, options?: ToolUITitleOptions) => string;
237
- meta: (meta: string[]) => string;
238
- count: (label: string, count: number) => string;
239
- moreItems: (remaining: number, itemType: string) => string;
240
- expandHint: (expanded: boolean, hasMore: boolean) => string;
241
- scope: (scopePath?: string) => string;
242
- truncationSuffix: (truncated: boolean) => string;
243
- errorMessage: (message: string | undefined) => string;
244
- emptyMessage: (message: string) => string;
245
- badge: (label: string, color: ToolUIColor) => string;
246
- statusIcon: (status: ToolUIStatus, spinnerFrame?: number) => string;
247
- wrapBrackets: (text: string) => string;
248
- truncate: (text: string, maxLen: number) => string;
249
- previewLines: (text: string, maxLines: number, maxLineLen: number) => string[];
250
- formatBytes: (bytes: number) => string;
251
- formatTokens: (tokens: number) => string;
252
- formatDuration: (ms: number) => string;
253
- formatAge: (ageSeconds: number | null | undefined) => string;
254
- formatDiagnostics: (
234
+ // =============================================================================
235
+ // Diagnostic Formatting
236
+ // =============================================================================
237
+
238
+ export class ToolUIKit {
239
+ constructor(public theme: Theme) {}
240
+
241
+ title(label: string, options?: ToolUITitleOptions): string {
242
+ const content = options?.bold === false ? label : this.theme.bold(label);
243
+ return this.theme.fg("toolTitle", content);
244
+ }
245
+
246
+ meta(meta: string[]): string {
247
+ return formatMeta(meta, this.theme);
248
+ }
249
+
250
+ count(label: string, count: number): string {
251
+ return formatCount(label, count);
252
+ }
253
+
254
+ moreItems(remaining: number, itemType: string): string {
255
+ return formatMoreItems(remaining, itemType, this.theme);
256
+ }
257
+
258
+ expandHint(expanded: boolean, hasMore: boolean): string {
259
+ return formatExpandHint(this.theme, expanded, hasMore);
260
+ }
261
+
262
+ scope(scopePath?: string): string {
263
+ return formatScope(scopePath, this.theme);
264
+ }
265
+
266
+ truncationSuffix(truncated: boolean): string {
267
+ return formatTruncationSuffix(truncated, this.theme);
268
+ }
269
+
270
+ errorMessage(message: string | undefined): string {
271
+ return formatErrorMessage(message, this.theme);
272
+ }
273
+
274
+ emptyMessage(message: string): string {
275
+ return formatEmptyMessage(message, this.theme);
276
+ }
277
+
278
+ badge(label: string, color: ToolUIColor): string {
279
+ return formatBadge(label, color, this.theme);
280
+ }
281
+
282
+ statusIcon(status: ToolUIStatus, spinnerFrame?: number): string {
283
+ return formatStatusIcon(status, this.theme, spinnerFrame);
284
+ }
285
+
286
+ wrapBrackets(text: string): string {
287
+ return wrapBrackets(text, this.theme);
288
+ }
289
+
290
+ truncate(text: string, maxLen: number): string {
291
+ return truncate(text, maxLen, this.theme.format.ellipsis);
292
+ }
293
+
294
+ previewLines(text: string, maxLines: number, maxLineLen: number): string[] {
295
+ return getPreviewLines(text, maxLines, maxLineLen, this.theme.format.ellipsis);
296
+ }
297
+
298
+ formatBytes(bytes: number): string {
299
+ return formatBytes(bytes);
300
+ }
301
+
302
+ formatTokens(tokens: number): string {
303
+ return formatTokens(tokens);
304
+ }
305
+
306
+ formatDuration(ms: number): string {
307
+ return formatDuration(ms);
308
+ }
309
+
310
+ formatAge(ageSeconds: number | null | undefined): string {
311
+ return formatAge(ageSeconds);
312
+ }
313
+
314
+ formatDiagnostics(
255
315
  diag: { errored: boolean; summary: string; messages: string[] },
256
316
  expanded: boolean,
257
317
  getLangIcon: (filePath: string) => string,
258
- ) => string;
259
- formatDiffStats: (added: number, removed: number, hunks: number) => string;
318
+ ): string {
319
+ return formatDiagnostics(diag, expanded, this.theme, getLangIcon);
320
+ }
321
+
322
+ formatDiffStats(added: number, removed: number, hunks: number): string {
323
+ return formatDiffStats(added, removed, hunks, this.theme);
324
+ }
260
325
  }
261
326
 
327
+ /** @deprecated Use `new ToolUIKit(theme)` instead */
262
328
  export function createToolUIKit(theme: Theme): ToolUIKit {
263
- return {
264
- theme,
265
- title: (label, options) => {
266
- const content = options?.bold === false ? label : theme.bold(label);
267
- return theme.fg("toolTitle", content);
268
- },
269
- meta: (meta) => formatMeta(meta, theme),
270
- count: (label, count) => formatCount(label, count),
271
- moreItems: (remaining, itemType) => formatMoreItems(remaining, itemType, theme),
272
- expandHint: (expanded, hasMore) => formatExpandHint(theme, expanded, hasMore),
273
- scope: (scopePath) => formatScope(scopePath, theme),
274
- truncationSuffix: (truncated) => formatTruncationSuffix(truncated, theme),
275
- errorMessage: (message) => formatErrorMessage(message, theme),
276
- emptyMessage: (message) => formatEmptyMessage(message, theme),
277
- badge: (label, color) => formatBadge(label, color, theme),
278
- statusIcon: (status, spinnerFrame) => formatStatusIcon(status, theme, spinnerFrame),
279
- wrapBrackets: (text) => wrapBrackets(text, theme),
280
- truncate: (text, maxLen) => truncate(text, maxLen, theme.format.ellipsis),
281
- previewLines: (text, maxLines, maxLineLen) => getPreviewLines(text, maxLines, maxLineLen, theme.format.ellipsis),
282
- formatBytes,
283
- formatTokens,
284
- formatDuration,
285
- formatAge,
286
- formatDiagnostics: (diag, expanded, getLangIcon) => formatDiagnostics(diag, expanded, theme, getLangIcon),
287
- formatDiffStats: (added, removed, hunks) => formatDiffStats(added, removed, hunks, theme),
288
- };
329
+ return new ToolUIKit(theme);
289
330
  }
290
331
 
291
- // =============================================================================
292
- // Diagnostic Formatting
293
- // =============================================================================
294
-
295
332
  interface ParsedDiagnostic {
296
333
  filePath: string;
297
334
  line: number;
@@ -477,6 +514,36 @@ export function formatDiffStats(added: number, removed: number, hunks: number, t
477
514
  return parts.join(theme.fg("dim", " / "));
478
515
  }
479
516
 
517
+ interface DiffSegment {
518
+ lines: string[];
519
+ isChange: boolean;
520
+ isEllipsis: boolean;
521
+ }
522
+
523
+ function parseDiffSegments(lines: string[]): DiffSegment[] {
524
+ const segments: DiffSegment[] = [];
525
+ let current: DiffSegment | null = null;
526
+
527
+ for (const line of lines) {
528
+ const isChange = line.startsWith("+") || line.startsWith("-");
529
+ const isEllipsis = line.trimStart().startsWith("...");
530
+
531
+ if (isEllipsis) {
532
+ if (current) segments.push(current);
533
+ segments.push({ lines: [line], isChange: false, isEllipsis: true });
534
+ current = null;
535
+ } else if (!current || current.isChange !== isChange) {
536
+ if (current) segments.push(current);
537
+ current = { lines: [line], isChange, isEllipsis: false };
538
+ } else {
539
+ current.lines.push(line);
540
+ }
541
+ }
542
+
543
+ if (current) segments.push(current);
544
+ return segments;
545
+ }
546
+
480
547
  export function truncateDiffByHunk(
481
548
  diffText: string,
482
549
  maxHunks: number,
@@ -484,42 +551,94 @@ export function truncateDiffByHunk(
484
551
  ): { text: string; hiddenHunks: number; hiddenLines: number } {
485
552
  const lines = diffText ? diffText.split("\n") : [];
486
553
  const totalStats = getDiffStats(diffText);
487
- const kept: string[] = [];
488
- let inHunk = false;
489
- let currentHunks = 0;
490
- let reachedLimit = false;
491
554
 
492
- for (const line of lines) {
493
- const isChange = line.startsWith("+") || line.startsWith("-");
494
- if (isChange && !inHunk) {
495
- currentHunks++;
496
- inHunk = true;
497
- }
498
- if (!isChange) {
499
- inHunk = false;
500
- }
555
+ if (lines.length <= maxLines && totalStats.hunks <= maxHunks) {
556
+ return { text: diffText, hiddenHunks: 0, hiddenLines: 0 };
557
+ }
501
558
 
502
- if (currentHunks > maxHunks) {
503
- reachedLimit = true;
504
- break;
505
- }
559
+ const segments = parseDiffSegments(lines);
560
+
561
+ const changeSegments = segments.filter((s) => s.isChange);
562
+ const changeLineCount = changeSegments.reduce((sum, s) => sum + s.lines.length, 0);
563
+
564
+ if (changeLineCount > maxLines) {
565
+ const kept: string[] = [];
566
+ let keptHunks = 0;
506
567
 
507
- kept.push(line);
508
- if (kept.length >= maxLines) {
509
- reachedLimit = true;
510
- break;
568
+ for (const seg of segments) {
569
+ if (seg.isChange) {
570
+ keptHunks++;
571
+ if (keptHunks > maxHunks) break;
572
+ }
573
+ kept.push(...seg.lines);
574
+ if (kept.length >= maxLines) break;
511
575
  }
576
+
577
+ const keptStats = getDiffStats(kept.join("\n"));
578
+ return {
579
+ text: kept.join("\n"),
580
+ hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
581
+ hiddenLines: Math.max(0, lines.length - kept.length),
582
+ };
512
583
  }
513
584
 
514
- if (!reachedLimit) {
515
- return { text: diffText, hiddenHunks: 0, hiddenLines: 0 };
585
+ const contextBudget = maxLines - changeLineCount;
586
+ const contextSegments = segments.filter((s) => !s.isChange && !s.isEllipsis);
587
+ const totalContextLines = contextSegments.reduce((sum, s) => sum + s.lines.length, 0);
588
+
589
+ const kept: string[] = [];
590
+ let keptHunks = 0;
591
+
592
+ if (totalContextLines <= contextBudget) {
593
+ for (const seg of segments) {
594
+ if (seg.isChange) {
595
+ keptHunks++;
596
+ if (keptHunks > maxHunks) break;
597
+ }
598
+ kept.push(...seg.lines);
599
+ }
600
+ } else {
601
+ const contextRatio = contextSegments.length > 0 ? contextBudget / totalContextLines : 0;
602
+
603
+ for (let i = 0; i < segments.length; i++) {
604
+ const seg = segments[i];
605
+
606
+ if (seg.isChange) {
607
+ keptHunks++;
608
+ if (keptHunks > maxHunks) break;
609
+ kept.push(...seg.lines);
610
+ } else if (seg.isEllipsis) {
611
+ kept.push(...seg.lines);
612
+ } else {
613
+ const allowedLines = Math.max(1, Math.floor(seg.lines.length * contextRatio));
614
+ const isBeforeChange = segments[i + 1]?.isChange;
615
+ const isAfterChange = segments[i - 1]?.isChange;
616
+
617
+ if (isBeforeChange && isAfterChange) {
618
+ const half = Math.ceil(allowedLines / 2);
619
+ if (seg.lines.length > allowedLines) {
620
+ kept.push(...seg.lines.slice(0, half));
621
+ kept.push(seg.lines[0].replace(/^(\s*\d*\s*).*/, "$1..."));
622
+ kept.push(...seg.lines.slice(-half));
623
+ } else {
624
+ kept.push(...seg.lines);
625
+ }
626
+ } else if (isBeforeChange) {
627
+ kept.push(...seg.lines.slice(-allowedLines));
628
+ } else if (isAfterChange) {
629
+ kept.push(...seg.lines.slice(0, allowedLines));
630
+ } else {
631
+ kept.push(...seg.lines.slice(0, Math.min(allowedLines, 2)));
632
+ }
633
+ }
634
+ }
516
635
  }
517
636
 
518
637
  const keptStats = getDiffStats(kept.join("\n"));
519
638
  return {
520
639
  text: kept.join("\n"),
521
640
  hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
522
- hiddenLines: Math.max(0, totalStats.lines - kept.length),
641
+ hiddenLines: Math.max(0, lines.length - kept.length),
523
642
  };
524
643
  }
525
644
 
@@ -10,13 +10,13 @@ import type { RenderResultOptions } from "../custom-tools/types";
10
10
  import { askToolRenderer } from "./ask";
11
11
  import { bashToolRenderer } from "./bash";
12
12
  import { calculatorToolRenderer } from "./calculator";
13
- import { editToolRenderer } from "./edit";
14
13
  import { findToolRenderer } from "./find";
15
14
  import { grepToolRenderer } from "./grep";
16
15
  import { lsToolRenderer } from "./ls";
17
16
  import { lspToolRenderer } from "./lsp/render";
18
17
  import { notebookToolRenderer } from "./notebook";
19
18
  import { outputToolRenderer } from "./output";
19
+ import { editToolRenderer } from "./patch";
20
20
  import { pythonToolRenderer } from "./python";
21
21
  import { readToolRenderer } from "./read";
22
22
  import { sshToolRenderer } from "./ssh";
@@ -1,4 +1,4 @@
1
- import type { AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Text } from "@oh-my-pi/pi-tui";
4
4
  import { Type } from "@sinclair/typebox";
@@ -112,97 +112,116 @@ async function loadHosts(session: ToolSession): Promise<{
112
112
  return { hostNames, hostsByName };
113
113
  }
114
114
 
115
- export async function createSshTool(session: ToolSession): Promise<AgentTool<typeof sshSchema> | null> {
116
- const { hostNames, hostsByName } = await loadHosts(session);
117
- if (hostNames.length === 0) {
118
- return null;
119
- }
115
+ interface SshToolParams {
116
+ host: string;
117
+ command: string;
118
+ cwd?: string;
119
+ timeout?: number;
120
+ }
120
121
 
121
- const allowedHosts = new Set(hostNames);
122
-
123
- const descriptionHosts = hostNames
124
- .map((name) => hostsByName.get(name))
125
- .filter((host): host is SSHHost => host !== undefined);
126
-
127
- return {
128
- name: "ssh",
129
- label: "SSH",
130
- description: formatDescription(descriptionHosts),
131
- parameters: sshSchema,
132
- execute: async (
133
- _toolCallId: string,
134
- { host, command, cwd, timeout }: { host: string; command: string; cwd?: string; timeout?: number },
135
- signal?: AbortSignal,
136
- onUpdate?,
137
- _ctx?: AgentToolContext,
138
- ) => {
139
- if (!allowedHosts.has(host)) {
140
- throw new Error(`Unknown SSH host: ${host}. Available hosts: ${hostNames.join(", ")}`);
141
- }
122
+ export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
123
+ public readonly name = "ssh";
124
+ public readonly label = "SSH";
125
+ public readonly description: string;
126
+ public readonly parameters = sshSchema;
142
127
 
143
- const hostConfig = hostsByName.get(host);
144
- if (!hostConfig) {
145
- throw new Error(`SSH host not loaded: ${host}`);
146
- }
128
+ private readonly allowedHosts: Set<string>;
129
+ private readonly hostsByName: Map<string, SSHHost>;
130
+ private readonly hostNames: string[];
147
131
 
148
- const hostInfo = await ensureHostInfo(hostConfig);
149
- const remoteCommand = buildRemoteCommand(command, cwd, hostInfo);
150
- let currentOutput = "";
151
-
152
- const result = await executeSSH(hostConfig, remoteCommand, {
153
- timeout: timeout ? timeout * 1000 : undefined,
154
- signal,
155
- compatEnabled: hostInfo.compatEnabled,
156
- onChunk: (chunk) => {
157
- currentOutput += chunk;
158
- if (onUpdate) {
159
- const truncation = truncateTail(currentOutput);
160
- onUpdate({
161
- content: [{ type: "text", text: truncation.content || "" }],
162
- details: {
163
- truncation: truncation.truncated ? truncation : undefined,
164
- },
165
- });
166
- }
167
- },
168
- });
169
-
170
- if (result.cancelled) {
171
- throw new Error(result.output || "Command aborted");
172
- }
132
+ constructor(hostNames: string[], hostsByName: Map<string, SSHHost>) {
133
+ this.hostNames = hostNames;
134
+ this.hostsByName = hostsByName;
135
+ this.allowedHosts = new Set(hostNames);
173
136
 
174
- const truncation = truncateTail(result.output);
175
- let outputText = truncation.content || "(no output)";
137
+ const descriptionHosts = hostNames
138
+ .map((name) => hostsByName.get(name))
139
+ .filter((host): host is SSHHost => host !== undefined);
176
140
 
177
- let details: SSHToolDetails | undefined;
141
+ this.description = formatDescription(descriptionHosts);
142
+ }
178
143
 
179
- if (truncation.truncated) {
180
- details = {
181
- truncation,
182
- fullOutputPath: result.fullOutputPath,
183
- };
144
+ public async execute(
145
+ _toolCallId: string,
146
+ { host, command, cwd, timeout }: SshToolParams,
147
+ signal?: AbortSignal,
148
+ onUpdate?: AgentToolUpdateCallback<SSHToolDetails>,
149
+ _ctx?: AgentToolContext,
150
+ ): Promise<AgentToolResult<SSHToolDetails>> {
151
+ if (!this.allowedHosts.has(host)) {
152
+ throw new Error(`Unknown SSH host: ${host}. Available hosts: ${this.hostNames.join(", ")}`);
153
+ }
184
154
 
185
- const startLine = truncation.totalLines - truncation.outputLines + 1;
186
- const endLine = truncation.totalLines;
155
+ const hostConfig = this.hostsByName.get(host);
156
+ if (!hostConfig) {
157
+ throw new Error(`SSH host not loaded: ${host}`);
158
+ }
187
159
 
188
- if (truncation.lastLinePartial) {
189
- const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
190
- outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`;
191
- } else if (truncation.truncatedBy === "lines") {
192
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`;
193
- } else {
194
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
160
+ const hostInfo = await ensureHostInfo(hostConfig);
161
+ const remoteCommand = buildRemoteCommand(command, cwd, hostInfo);
162
+ let currentOutput = "";
163
+
164
+ const result = await executeSSH(hostConfig, remoteCommand, {
165
+ timeout: timeout ? timeout * 1000 : undefined,
166
+ signal,
167
+ compatEnabled: hostInfo.compatEnabled,
168
+ onChunk: (chunk) => {
169
+ currentOutput += chunk;
170
+ if (onUpdate) {
171
+ const truncation = truncateTail(currentOutput);
172
+ onUpdate({
173
+ content: [{ type: "text", text: truncation.content || "" }],
174
+ details: {
175
+ truncation: truncation.truncated ? truncation : undefined,
176
+ },
177
+ });
195
178
  }
196
- }
179
+ },
180
+ });
181
+
182
+ if (result.cancelled) {
183
+ throw new Error(result.output || "Command aborted");
184
+ }
197
185
 
198
- if (result.exitCode !== 0 && result.exitCode !== undefined) {
199
- outputText += `\n\nCommand exited with code ${result.exitCode}`;
200
- throw new Error(outputText);
186
+ const truncation = truncateTail(result.output);
187
+ let outputText = truncation.content || "(no output)";
188
+
189
+ let details: SSHToolDetails | undefined;
190
+
191
+ if (truncation.truncated) {
192
+ details = {
193
+ truncation,
194
+ fullOutputPath: result.fullOutputPath,
195
+ };
196
+
197
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
198
+ const endLine = truncation.totalLines;
199
+
200
+ if (truncation.lastLinePartial) {
201
+ const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
202
+ outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`;
203
+ } else if (truncation.truncatedBy === "lines") {
204
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`;
205
+ } else {
206
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
201
207
  }
208
+ }
202
209
 
203
- return { content: [{ type: "text", text: outputText }], details };
204
- },
205
- };
210
+ if (result.exitCode !== 0 && result.exitCode !== undefined) {
211
+ outputText += `\n\nCommand exited with code ${result.exitCode}`;
212
+ throw new Error(outputText);
213
+ }
214
+
215
+ return { content: [{ type: "text", text: outputText }], details: details ?? {} };
216
+ }
217
+ }
218
+
219
+ export async function loadSshTool(session: ToolSession): Promise<SshTool | null> {
220
+ const { hostNames, hostsByName } = await loadHosts(session);
221
+ if (hostNames.length === 0) {
222
+ return null;
223
+ }
224
+ return new SshTool(hostNames, hostsByName);
206
225
  }
207
226
 
208
227
  // =============================================================================
@@ -12,9 +12,9 @@ import type { MCPManager } from "../../mcp/manager";
12
12
  import type { ModelRegistry } from "../../model-registry";
13
13
  import { checkPythonKernelAvailability } from "../../python-kernel";
14
14
  import type { ToolSession } from "..";
15
- import { createLspTool } from "../lsp/index";
15
+ import { LspTool } from "../lsp/index";
16
16
  import type { LspParams } from "../lsp/types";
17
- import { createPythonTool } from "../python";
17
+ import { PythonTool } from "../python";
18
18
  import { ensureArtifactsDir, getArtifactPaths } from "./artifacts";
19
19
  import { resolveModelPattern } from "./model-resolver";
20
20
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
@@ -388,7 +388,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
388
388
  settings: options.settingsManager as ToolSession["settings"],
389
389
  settingsManager: options.settingsManager,
390
390
  };
391
- const pythonTool = pythonProxyEnabled ? createPythonTool(pythonToolSession) : null;
391
+ const pythonTool = pythonProxyEnabled ? new PythonTool(pythonToolSession) : null;
392
392
  const pythonCallControllers = new Map<string, AbortController>();
393
393
 
394
394
  const lspToolSession: ToolSession = {
@@ -400,7 +400,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
400
400
  settings: options.settingsManager as ToolSession["settings"],
401
401
  settingsManager: options.settingsManager,
402
402
  };
403
- const lspTool = lspToolRequested ? createLspTool(lspToolSession) : null;
403
+ const lspTool = lspToolRequested ? new LspTool(lspToolSession) : null;
404
404
 
405
405
  // Accumulate usage incrementally from message_end events (no memory for streaming events)
406
406
  const accumulatedUsage = {
@@ -736,9 +736,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
736
736
  try {
737
737
  const parsed = parseMCPToolName(request.toolName);
738
738
  if (!parsed) throw new Error(`Invalid MCP tool name: ${request.toolName}`);
739
- const connection = mcpManager.getConnection(parsed.serverName);
740
- if (!connection) throw new Error(`MCP server not connected: ${parsed.serverName}`);
741
- const result = await withTimeout(callTool(connection, parsed.toolName, request.params), request.timeoutMs);
739
+ const result = await withTimeout(
740
+ (async () => {
741
+ const connection = await mcpManager.waitForConnection(parsed.serverName);
742
+ return callTool(connection, parsed.toolName, request.params);
743
+ })(),
744
+ request.timeoutMs,
745
+ );
742
746
  worker.postMessage({
743
747
  type: "mcp_tool_result",
744
748
  callId: request.callId,