@oh-my-pi/pi-coding-agent 14.1.1 → 14.2.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 (123) hide show
  1. package/CHANGELOG.md +47 -2
  2. package/package.json +8 -8
  3. package/scripts/build-binary.ts +61 -0
  4. package/src/autoresearch/helpers.ts +10 -0
  5. package/src/autoresearch/index.ts +1 -11
  6. package/src/autoresearch/tools/init-experiment.ts +1 -10
  7. package/src/autoresearch/tools/log-experiment.ts +1 -11
  8. package/src/autoresearch/tools/run-experiment.ts +1 -10
  9. package/src/bun-imports.d.ts +6 -0
  10. package/src/cli/plugin-cli.ts +23 -45
  11. package/src/commit/agentic/tools/propose-commit.ts +1 -14
  12. package/src/commit/agentic/tools/split-commit.ts +1 -15
  13. package/src/commit/utils.ts +15 -1
  14. package/src/config/model-registry.ts +3 -3
  15. package/src/config/prompt-templates.ts +4 -12
  16. package/src/config/settings-schema.ts +27 -2
  17. package/src/config/settings.ts +1 -1
  18. package/src/discovery/claude-plugins.ts +61 -6
  19. package/src/discovery/codex.ts +2 -15
  20. package/src/discovery/gemini.ts +2 -15
  21. package/src/discovery/helpers.ts +40 -1
  22. package/src/discovery/opencode.ts +2 -15
  23. package/src/edit/apply-patch/index.ts +87 -0
  24. package/src/edit/apply-patch/parser.ts +174 -0
  25. package/src/edit/diff.ts +3 -14
  26. package/src/edit/index.ts +65 -2
  27. package/src/edit/modes/apply-patch.lark +19 -0
  28. package/src/edit/modes/apply-patch.ts +63 -0
  29. package/src/edit/modes/hashline.ts +3 -3
  30. package/src/edit/modes/replace.ts +2 -13
  31. package/src/edit/read-file.ts +18 -0
  32. package/src/edit/renderer.ts +61 -33
  33. package/src/extensibility/extensions/compact-handler.ts +40 -0
  34. package/src/extensibility/extensions/runner.ts +11 -29
  35. package/src/extensibility/utils.ts +7 -1
  36. package/src/internal-urls/docs-index.generated.ts +9 -2
  37. package/src/lsp/render.ts +14 -2
  38. package/src/main.ts +1 -0
  39. package/src/mcp/manager.ts +29 -48
  40. package/src/memories/index.ts +7 -1
  41. package/src/modes/acp/acp-agent.ts +3 -16
  42. package/src/modes/components/model-selector.ts +15 -24
  43. package/src/modes/components/plugin-settings.ts +16 -5
  44. package/src/modes/components/read-tool-group.ts +92 -9
  45. package/src/modes/components/settings-defs.ts +18 -0
  46. package/src/modes/components/settings-selector.ts +2 -6
  47. package/src/modes/components/tool-execution.ts +61 -28
  48. package/src/modes/controllers/event-controller.ts +3 -1
  49. package/src/modes/controllers/extension-ui-controller.ts +99 -150
  50. package/src/modes/controllers/selector-controller.ts +3 -12
  51. package/src/modes/interactive-mode.ts +4 -2
  52. package/src/modes/print-mode.ts +4 -22
  53. package/src/modes/rpc/rpc-mode.ts +18 -38
  54. package/src/modes/shared.ts +10 -1
  55. package/src/modes/utils/ui-helpers.ts +6 -2
  56. package/src/plan-mode/approved-plan.ts +5 -4
  57. package/src/prompts/system/subagent-system-prompt.md +4 -4
  58. package/src/prompts/system/subagent-user-prompt.md +2 -2
  59. package/src/prompts/system/system-prompt.md +208 -243
  60. package/src/prompts/tools/apply-patch.md +67 -0
  61. package/src/prompts/tools/ast-edit.md +18 -23
  62. package/src/prompts/tools/ast-grep.md +24 -32
  63. package/src/prompts/tools/bash.md +11 -23
  64. package/src/prompts/tools/debug.md +8 -22
  65. package/src/prompts/tools/find.md +0 -4
  66. package/src/prompts/tools/grep.md +3 -5
  67. package/src/prompts/tools/hashline.md +16 -10
  68. package/src/prompts/tools/python.md +10 -14
  69. package/src/prompts/tools/read.md +17 -24
  70. package/src/prompts/tools/task.md +57 -21
  71. package/src/prompts/tools/todo-write.md +45 -67
  72. package/src/session/agent-session.ts +4 -4
  73. package/src/session/session-manager.ts +15 -7
  74. package/src/session/streaming-output.ts +24 -0
  75. package/src/slash-commands/builtin-registry.ts +3 -14
  76. package/src/task/executor.ts +13 -34
  77. package/src/task/index.ts +82 -18
  78. package/src/task/simple-mode.ts +27 -0
  79. package/src/task/template.ts +17 -3
  80. package/src/task/types.ts +77 -30
  81. package/src/tools/ask.ts +2 -4
  82. package/src/tools/ast-edit.ts +4 -15
  83. package/src/tools/ast-grep.ts +8 -27
  84. package/src/tools/bash-skill-urls.ts +9 -7
  85. package/src/tools/bash.ts +4 -12
  86. package/src/tools/browser.ts +1 -1
  87. package/src/tools/fetch.ts +1 -14
  88. package/src/tools/file-recorder.ts +35 -0
  89. package/src/tools/find.ts +6 -3
  90. package/src/tools/gh-format.ts +12 -0
  91. package/src/tools/gh-renderer.ts +1 -8
  92. package/src/tools/gh.ts +6 -13
  93. package/src/tools/grep.ts +9 -22
  94. package/src/tools/jtd-to-json-schema.ts +16 -0
  95. package/src/tools/match-line-format.ts +20 -0
  96. package/src/tools/path-utils.ts +30 -2
  97. package/src/tools/plan-mode-guard.ts +6 -5
  98. package/src/tools/python.ts +1 -1
  99. package/src/tools/read.ts +1 -1
  100. package/src/tools/render-utils.ts +38 -6
  101. package/src/tools/renderers.ts +1 -0
  102. package/src/tools/ssh.ts +3 -11
  103. package/src/tools/submit-result.ts +1 -13
  104. package/src/tools/todo-write.ts +137 -103
  105. package/src/tools/write.ts +2 -23
  106. package/src/tui/code-cell.ts +12 -7
  107. package/src/utils/edit-mode.ts +3 -2
  108. package/src/utils/git.ts +1 -1
  109. package/src/vim/engine.ts +41 -58
  110. package/src/web/scrapers/crates-io.ts +1 -14
  111. package/src/web/scrapers/types.ts +13 -0
  112. package/src/web/search/providers/base.ts +13 -0
  113. package/src/web/search/providers/brave.ts +2 -5
  114. package/src/web/search/providers/codex.ts +20 -24
  115. package/src/web/search/providers/gemini.ts +39 -1
  116. package/src/web/search/providers/jina.ts +2 -5
  117. package/src/web/search/providers/kagi.ts +3 -8
  118. package/src/web/search/providers/kimi.ts +3 -7
  119. package/src/web/search/providers/parallel.ts +3 -8
  120. package/src/web/search/providers/synthetic.ts +3 -7
  121. package/src/web/search/providers/tavily.ts +15 -11
  122. package/src/web/search/providers/utils.ts +36 -0
  123. package/src/web/search/providers/zai.ts +3 -7
@@ -18,7 +18,7 @@ import { ensureTool } from "../utils/tools-manager";
18
18
  import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel";
19
19
  import { specialHandlers } from "../web/scrapers";
20
20
  import type { RenderResult } from "../web/scrapers/types";
21
- import { finalizeOutput, loadPage, MAX_OUTPUT_CHARS } from "../web/scrapers/types";
21
+ import { finalizeOutput, loadPage, looksLikeHtml, MAX_OUTPUT_CHARS } from "../web/scrapers/types";
22
22
  import { convertWithMarkit, fetchBinary } from "../web/scrapers/utils";
23
23
  import { applyListLimit } from "./list-limit";
24
24
  import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
@@ -253,19 +253,6 @@ function isInlineImageMimeTypeSupported(mimeType: string): boolean {
253
253
  return SUPPORTED_INLINE_IMAGE_MIME_TYPES.has(mimeType);
254
254
  }
255
255
 
256
- /**
257
- * Check if content looks like HTML
258
- */
259
- function looksLikeHtml(content: string): boolean {
260
- const trimmed = content.trim().toLowerCase();
261
- return (
262
- trimmed.startsWith("<!doctype") ||
263
- trimmed.startsWith("<html") ||
264
- trimmed.startsWith("<head") ||
265
- trimmed.startsWith("<body")
266
- );
267
- }
268
-
269
256
  /**
270
257
  * Try fetching URL with .md appended (llms.txt convention)
271
258
  */
@@ -0,0 +1,35 @@
1
+ import * as path from "node:path";
2
+
3
+ /**
4
+ * Creates a deduplicating recorder for relative file paths.
5
+ * Preserves insertion order in `list`; subsequent duplicates are ignored.
6
+ */
7
+ export function createFileRecorder(): {
8
+ record: (relativePath: string) => void;
9
+ list: string[];
10
+ } {
11
+ const seen = new Set<string>();
12
+ const list: string[] = [];
13
+ return {
14
+ record(relativePath: string) {
15
+ if (!seen.has(relativePath)) {
16
+ seen.add(relativePath);
17
+ list.push(relativePath);
18
+ }
19
+ },
20
+ list,
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Strip a leading slash and, when the search scope is a directory, normalize
26
+ * Windows-style separators. For single-file scopes, fall back to the basename
27
+ * so tool output does not leak absolute paths.
28
+ */
29
+ export function formatResultPath(filePath: string, isDirectory: boolean): string {
30
+ const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
31
+ if (isDirectory) {
32
+ return cleanPath.replace(/\\/g, "/");
33
+ }
34
+ return path.basename(cleanPath);
35
+ }
package/src/tools/find.ts CHANGED
@@ -29,9 +29,12 @@ import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
29
29
  import { toolResult } from "./tool-result";
30
30
 
31
31
  const findSchema = Type.Object({
32
- pattern: Type.String({ description: "Glob pattern, e.g. '*.ts', 'src/**/*.json', 'lib/*.tsx'" }),
33
- hidden: Type.Optional(Type.Boolean({ description: "Include hidden files and directories (default: true)" })),
34
- limit: Type.Optional(Type.Number({ description: "Max results (default: 1000)" })),
32
+ pattern: Type.String({
33
+ description:
34
+ "Glob pattern including the search path (no separate path param), e.g. 'src/**/*.ts', 'lib/*.json'. Supports comma-separated lists like 'apps/,packages/,phases/'. Simple patterns like '*.ts' recurse from cwd.",
35
+ }),
36
+ hidden: Type.Optional(Type.Boolean({ description: "Include hidden files and directories", default: true })),
37
+ limit: Type.Optional(Type.Number({ description: "Max results", default: 1000 })),
35
38
  });
36
39
 
37
40
  export type FindToolInput = Static<typeof findSchema>;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Return the first 12 hex characters of a commit SHA, or undefined when the
3
+ * input is missing. Shared between GitHub tool argument normalization and the
4
+ * run-watch renderer.
5
+ */
6
+ export function formatShortSha(value: string | undefined): string | undefined {
7
+ if (!value) {
8
+ return undefined;
9
+ }
10
+
11
+ return value.slice(0, 12);
12
+ }
@@ -9,6 +9,7 @@ import type {
9
9
  GhRunWatchViewDetails,
10
10
  GhToolDetails,
11
11
  } from "./gh";
12
+ import { formatShortSha } from "./gh-format";
12
13
  import {
13
14
  formatExpandHint,
14
15
  formatStatusIcon,
@@ -29,14 +30,6 @@ const RUNNING_STATUSES = new Set(["in_progress"]);
29
30
  const PENDING_STATUSES = new Set(["queued", "requested", "waiting", "pending"]);
30
31
  const FALLBACK_WIDTH = 80;
31
32
 
32
- function formatShortSha(value: string | undefined): string | undefined {
33
- if (!value) {
34
- return undefined;
35
- }
36
-
37
- return value.slice(0, 12);
38
- }
39
-
40
33
  function getWatchHeader(watch: GhRunWatchViewDetails): string {
41
34
  if (watch.mode === "run" && watch.run) {
42
35
  if (watch.state === "watching") {
package/src/tools/gh.ts CHANGED
@@ -14,6 +14,7 @@ import ghSearchIssuesDescription from "../prompts/tools/gh-search-issues.md" wit
14
14
  import ghSearchPrsDescription from "../prompts/tools/gh-search-prs.md" with { type: "text" };
15
15
  import * as git from "../utils/git";
16
16
  import type { ToolSession } from ".";
17
+ import { formatShortSha } from "./gh-format";
17
18
  import type { OutputMeta } from "./output-meta";
18
19
  import { ToolError, throwIfAborted } from "./tool-errors";
19
20
  import { toolResult } from "./tool-result";
@@ -148,7 +149,7 @@ const ghIssueViewSchema = Type.Object({
148
149
  repo: Type.Optional(
149
150
  Type.String({ description: "Repository in OWNER/REPO format. Omit when passing a full issue URL." }),
150
151
  ),
151
- comments: Type.Optional(Type.Boolean({ description: "Include issue comments (default: true)." })),
152
+ comments: Type.Optional(Type.Boolean({ description: "Include issue comments.", default: true })),
152
153
  });
153
154
 
154
155
  const ghPrViewSchema = Type.Object({
@@ -161,7 +162,7 @@ const ghPrViewSchema = Type.Object({
161
162
  repo: Type.Optional(
162
163
  Type.String({ description: "Repository in OWNER/REPO format. Omit when passing a full pull request URL." }),
163
164
  ),
164
- comments: Type.Optional(Type.Boolean({ description: "Include pull request comments (default: true)." })),
165
+ comments: Type.Optional(Type.Boolean({ description: "Include pull request comments.", default: true })),
165
166
  });
166
167
 
167
168
  const ghPrDiffSchema = Type.Object({
@@ -217,13 +218,13 @@ const ghPrPushSchema = Type.Object({
217
218
  const ghSearchIssuesSchema = Type.Object({
218
219
  query: Type.String({ description: "GitHub issue search query. Supports GitHub search syntax." }),
219
220
  repo: Type.Optional(Type.String({ description: "Repository in OWNER/REPO format to scope the search." })),
220
- limit: Type.Optional(Type.Number({ description: "Maximum results to return (default: 10, max: 50)." })),
221
+ limit: Type.Optional(Type.Number({ description: "Maximum results to return (max: 50).", default: 10 })),
221
222
  });
222
223
 
223
224
  const ghSearchPrsSchema = Type.Object({
224
225
  query: Type.String({ description: "GitHub pull request search query. Supports GitHub search syntax." }),
225
226
  repo: Type.Optional(Type.String({ description: "Repository in OWNER/REPO format to scope the search." })),
226
- limit: Type.Optional(Type.Number({ description: "Maximum results to return (default: 10, max: 50)." })),
227
+ limit: Type.Optional(Type.Number({ description: "Maximum results to return (max: 50).", default: 10 })),
227
228
  });
228
229
 
229
230
  const ghRunWatchSchema = Type.Object({
@@ -239,7 +240,7 @@ const ghRunWatchSchema = Type.Object({
239
240
  }),
240
241
  ),
241
242
  tail: Type.Optional(
242
- Type.Number({ description: "Number of log lines to include per failed job (default: 15, max: 200)." }),
243
+ Type.Number({ description: "Number of log lines to include per failed job (max: 200).", default: 15 }),
243
244
  ),
244
245
  });
245
246
 
@@ -545,14 +546,6 @@ function normalizeOptionalString(value: string | null | undefined): string | und
545
546
  return normalized ? normalized : undefined;
546
547
  }
547
548
 
548
- function formatShortSha(value: string | undefined): string | undefined {
549
- if (!value) {
550
- return undefined;
551
- }
552
-
553
- return value.slice(0, 12);
554
- }
555
-
556
549
  function requireNonEmpty(value: string | null | undefined, label: string): string {
557
550
  const normalized = normalizeOptionalString(value);
558
551
  if (!normalized) {
package/src/tools/grep.ts CHANGED
@@ -6,7 +6,6 @@ import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
- import { computeLineHash } from "../edit/line-hash";
10
9
  import { type ChunkedGrepMatch, describeChunkedGrepMatch } from "../edit/modes/chunk";
11
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
11
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
@@ -16,6 +15,8 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
16
15
  import { resolveEditMode } from "../utils/edit-mode";
17
16
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
18
17
  import type { ToolSession } from ".";
18
+ import { createFileRecorder } from "./file-recorder";
19
+ import { formatMatchLine } from "./match-line-format";
19
20
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
20
21
  import {
21
22
  combineSearchGlobs,
@@ -34,13 +35,13 @@ const grepSchema = Type.Object({
34
35
  path: Type.Optional(Type.String({ description: "File or directory to search (default: cwd)" })),
35
36
  glob: Type.Optional(Type.String({ description: "Filter files by glob pattern (e.g., '*.js')" })),
36
37
  type: Type.Optional(Type.String({ description: "Filter by file type (e.g., js, py, rust)" })),
37
- i: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
38
+ i: Type.Optional(Type.Boolean({ description: "Case-insensitive search", default: false })),
38
39
  pre: Type.Optional(Type.Number({ description: "Lines of context before matches" })),
39
40
  post: Type.Optional(Type.Number({ description: "Lines of context after matches" })),
40
41
  multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching" })),
41
- gitignore: Type.Optional(Type.Boolean({ description: "Respect .gitignore files during search (default: true)" })),
42
- limit: Type.Optional(Type.Number({ description: "Limit output to first N matches (default: 20)" })),
43
- offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })),
42
+ gitignore: Type.Optional(Type.Boolean({ description: "Respect .gitignore files during search", default: true })),
43
+ limit: Type.Optional(Type.Number({ description: "Limit output to first N matches", default: 20 })),
44
+ offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit", default: 0 })),
44
45
  });
45
46
 
46
47
  export type GrepToolInput = Static<typeof grepSchema>;
@@ -243,15 +244,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
243
244
  ? roundRobinSelect(result.matches, effectiveLimit)
244
245
  : result.matches.slice(0, effectiveLimit);
245
246
  const matchLimitReached = result.matches.length > effectiveLimit;
246
- const files = new Set<string>();
247
- const fileList: string[] = [];
247
+ const { record: recordFile, list: fileList } = createFileRecorder();
248
248
  const fileMatchCounts = new Map<string, number>();
249
- const recordFile = (relativePath: string) => {
250
- if (!files.has(relativePath)) {
251
- files.add(relativePath);
252
- fileList.push(relativePath);
253
- }
254
- };
255
249
  if (selectedMatches.length === 0) {
256
250
  const details: GrepToolDetails = {
257
251
  scopePath,
@@ -401,15 +395,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
401
395
  }
402
396
  }
403
397
  const lineWidth = Math.max(...lineNumbers.map(value => value.toString().length));
404
- const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => {
405
- const separator = isMatch ? ":" : "-";
406
- if (useHashLines) {
407
- const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`;
408
- return `${ref}${separator}${line}`;
409
- }
410
- const padded = lineNumber.toString().padStart(lineWidth, " ");
411
- return `${padded}${separator}${line}`;
412
- };
398
+ const formatLine = (lineNumber: number, line: string, isMatch: boolean): string =>
399
+ formatMatchLine(lineNumber, line, isMatch, { useHashLines, lineWidth });
413
400
  if (match.contextBefore) {
414
401
  for (const ctx of match.contextBefore) {
415
402
  outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
@@ -197,3 +197,19 @@ function normalizeMixedSchemaNode(schema: unknown): unknown {
197
197
  export function jtdToJsonSchema(schema: unknown): unknown {
198
198
  return normalizeMixedSchemaNode(schema);
199
199
  }
200
+
201
+ /**
202
+ * Normalize a schema input that may be a JSON string, object, or null/undefined.
203
+ * Returns { normalized } on success, or { error } if JSON parsing fails.
204
+ */
205
+ export function normalizeSchema(schema: unknown): { normalized?: unknown; error?: string } {
206
+ if (schema === undefined || schema === null) return {};
207
+ if (typeof schema === "string") {
208
+ try {
209
+ return { normalized: JSON.parse(schema) };
210
+ } catch (err) {
211
+ return { error: err instanceof Error ? err.message : String(err) };
212
+ }
213
+ }
214
+ return { normalized: schema };
215
+ }
@@ -0,0 +1,20 @@
1
+ import { computeLineHash } from "../edit/line-hash";
2
+
3
+ /**
4
+ * Format a single line of match output for grep/ast-grep style results.
5
+ * Uses hashline refs when hashlines are enabled, otherwise pads the number.
6
+ */
7
+ export function formatMatchLine(
8
+ lineNumber: number,
9
+ line: string,
10
+ isMatch: boolean,
11
+ options: { useHashLines: boolean; lineWidth: number },
12
+ ): string {
13
+ const separator = isMatch ? ":" : "-";
14
+ if (options.useHashLines) {
15
+ const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`;
16
+ return `${ref}${separator}${line}`;
17
+ }
18
+ const padded = lineNumber.toString().padStart(options.lineWidth, " ");
19
+ return `${padded}${separator}${line}`;
20
+ }
@@ -74,7 +74,7 @@ function normalizeAtPrefix(filePath: string): string {
74
74
  withoutAt.startsWith("artifact://") ||
75
75
  withoutAt.startsWith("skill://") ||
76
76
  withoutAt.startsWith("rule://") ||
77
- withoutAt.startsWith("local://") ||
77
+ withoutAt.startsWith("local:") ||
78
78
  withoutAt.startsWith("mcp://")
79
79
  ) {
80
80
  return withoutAt;
@@ -110,6 +110,29 @@ export function expandPath(filePath: string): string {
110
110
  return expandTilde(normalized);
111
111
  }
112
112
 
113
+ function assertNotInternalUrl(expanded: string, original: string): void {
114
+ for (const prefix of TOP_LEVEL_INTERNAL_URL_PREFIXES) {
115
+ if (expanded.startsWith(prefix)) {
116
+ throw new Error(
117
+ `Path "${original}" uses internal scheme "${prefix}" and must be resolved through the proper protocol handler, not as a filesystem path.`,
118
+ );
119
+ }
120
+ }
121
+ }
122
+
123
+ export function normalizeLocalScheme(filePath: string): string {
124
+ return filePath.replace(/^(local:)\/(?!\/)/, "$1//");
125
+ }
126
+
127
+ export function isInternalUrlPath(filePath: string): boolean {
128
+ const normalized = normalizeLocalScheme(filePath);
129
+ const expandedAndNormalized = normalizeLocalScheme(expandPath(normalized));
130
+ for (const prefix of TOP_LEVEL_INTERNAL_URL_PREFIXES) {
131
+ if (expandedAndNormalized.startsWith(prefix)) return true;
132
+ }
133
+ return false;
134
+ }
135
+
113
136
  /**
114
137
  * Resolve a path relative to the given cwd.
115
138
  * Handles ~ expansion and absolute paths.
@@ -119,7 +142,12 @@ export function expandPath(filePath: string): string {
119
142
  * filesystem root is almost never what they intended.
120
143
  */
121
144
  export function resolveToCwd(filePath: string, cwd: string): string {
122
- const expanded = expandPath(filePath);
145
+ const normalized = normalizeLocalScheme(filePath);
146
+ const expanded = expandPath(normalized);
147
+ const expandedAndNormalized = normalizeLocalScheme(expanded);
148
+
149
+ assertNotInternalUrl(expandedAndNormalized, normalized);
150
+
123
151
  if (/^\/+$/.test(expanded)) {
124
152
  return cwd;
125
153
  }
@@ -1,19 +1,20 @@
1
1
  import { resolveLocalUrlToPath } from "../internal-urls";
2
2
  import type { ToolSession } from ".";
3
- import { resolveToCwd } from "./path-utils";
3
+ import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
4
4
  import { ToolError } from "./tool-errors";
5
5
 
6
- const LOCAL_URL_PREFIX = "local://";
6
+ const LOCAL_SCHEME_PREFIX = "local:";
7
7
 
8
8
  export function resolvePlanPath(session: ToolSession, targetPath: string): string {
9
- if (targetPath.startsWith(LOCAL_URL_PREFIX)) {
10
- return resolveLocalUrlToPath(targetPath, {
9
+ const normalized = normalizeLocalScheme(targetPath);
10
+ if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) {
11
+ return resolveLocalUrlToPath(normalized, {
11
12
  getArtifactsDir: session.getArtifactsDir,
12
13
  getSessionId: session.getSessionId,
13
14
  });
14
15
  }
15
16
 
16
- return resolveToCwd(targetPath, session.cwd);
17
+ return resolveToCwd(normalized, session.cwd);
17
18
  }
18
19
 
19
20
  export function enforcePlanModeWrite(
@@ -52,7 +52,7 @@ export const pythonSchema = Type.Object({
52
52
  }),
53
53
  { description: "Cells to execute sequentially in persistent kernel" },
54
54
  ),
55
- timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 30)" })),
55
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds", default: 30 })),
56
56
  cwd: Type.Optional(Type.String({ description: "Working directory (default: cwd)" })),
57
57
  reset: Type.Optional(Type.Boolean({ description: "Restart kernel before execution" })),
58
58
  });
package/src/tools/read.ts CHANGED
@@ -371,7 +371,7 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
371
371
  const readSchema = Type.Object({
372
372
  path: Type.String({ description: "Path or URL to read" }),
373
373
  sel: Type.Optional(Type.String({ description: "Selector: chunk path, L10-L50, or raw" })),
374
- timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 20)" })),
374
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds", default: 20 })),
375
375
  });
376
376
 
377
377
  export type ReadToolInput = Static<typeof readSchema>;
@@ -4,8 +4,10 @@
4
4
  * Provides consistent formatting, truncation, and display patterns across all
5
5
  * tool renderers to ensure a unified TUI experience.
6
6
  */
7
+
7
8
  import * as os from "node:os";
8
9
  import * as path from "node:path";
10
+ import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
9
11
  import type { Ellipsis } from "@oh-my-pi/pi-natives";
10
12
  import { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
11
13
  import { pluralize } from "@oh-my-pi/pi-utils";
@@ -210,6 +212,10 @@ interface ParsedDiagnostic {
210
212
  code?: string;
211
213
  }
212
214
 
215
+ function sanitizeDiagnosticDisplayText(text: string): string {
216
+ return replaceTabs(text);
217
+ }
218
+
213
219
  function getSeverityRank(severity: ParsedDiagnostic["severity"]): number {
214
220
  switch (severity) {
215
221
  case "error":
@@ -227,13 +233,13 @@ function parseDiagnosticMessage(msg: string): ParsedDiagnostic | null {
227
233
  const match = msg.match(/^(.+?):(\d+):(\d+)\s+\[(\w+)\]\s+(?:\[([^\]]+)\]\s+)?(.+?)(?:\s+\(([^)]+)\))?$/);
228
234
  if (!match) return null;
229
235
  return {
230
- filePath: match[1],
236
+ filePath: sanitizeDiagnosticDisplayText(match[1]),
231
237
  line: parseInt(match[2], 10),
232
238
  col: parseInt(match[3], 10),
233
239
  severity: match[4] as ParsedDiagnostic["severity"],
234
- source: match[5],
235
- message: match[6],
236
- code: match[7],
240
+ source: match[5] ? sanitizeDiagnosticDisplayText(match[5]) : undefined,
241
+ message: sanitizeDiagnosticDisplayText(match[6]),
242
+ code: match[7] ? sanitizeDiagnosticDisplayText(match[7]) : undefined,
237
243
  };
238
244
  }
239
245
 
@@ -255,7 +261,7 @@ export function formatDiagnostics(
255
261
  existing.push(parsed);
256
262
  byFile.set(parsed.filePath, existing);
257
263
  } else {
258
- unparsed.push(msg);
264
+ unparsed.push(sanitizeDiagnosticDisplayText(msg));
259
265
  }
260
266
  }
261
267
 
@@ -272,7 +278,8 @@ export function formatDiagnostics(
272
278
  const headerIcon = diag.errored
273
279
  ? theme.styledSymbol("status.error", "error")
274
280
  : theme.styledSymbol("status.warning", "warning");
275
- let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
281
+ const summary = sanitizeDiagnosticDisplayText(diag.summary);
282
+ let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${summary})`)}`;
276
283
 
277
284
  const maxDiags = expanded ? diag.messages.length : 5;
278
285
  let diagsShown = 0;
@@ -616,3 +623,28 @@ export function formatParseErrors(errors: string[]): string[] {
616
623
  : "Parse issues:";
617
624
  return [header, ...capped.map(err => `- ${err}`)];
618
625
  }
626
+
627
+ // =============================================================================
628
+ // LSP Batching
629
+ // =============================================================================
630
+
631
+ const LSP_BATCH_TOOLS = new Set(["edit", "write"]);
632
+
633
+ export interface LspBatchRequest {
634
+ id: string;
635
+ flush: boolean;
636
+ }
637
+
638
+ export function getLspBatchRequest(toolCall: ToolCallContext | undefined): LspBatchRequest | undefined {
639
+ if (!toolCall) {
640
+ return undefined;
641
+ }
642
+ const hasOtherWrites = toolCall.toolCalls.some(
643
+ (call, index) => index !== toolCall.index && LSP_BATCH_TOOLS.has(call.name),
644
+ );
645
+ if (!hasOtherWrites) {
646
+ return undefined;
647
+ }
648
+ const hasLaterWrites = toolCall.toolCalls.slice(toolCall.index + 1).some(call => LSP_BATCH_TOOLS.has(call.name));
649
+ return { id: toolCall.batchId, flush: !hasLaterWrites };
650
+ }
@@ -51,6 +51,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
51
51
  python: pythonToolRenderer as ToolRenderer,
52
52
  calc: calculatorToolRenderer as ToolRenderer,
53
53
  edit: editToolRenderer as ToolRenderer,
54
+ apply_patch: editToolRenderer as ToolRenderer,
54
55
  find: findToolRenderer as ToolRenderer,
55
56
  grep: grepToolRenderer as ToolRenderer,
56
57
  lsp: lspToolRenderer as ToolRenderer,
package/src/tools/ssh.ts CHANGED
@@ -9,7 +9,7 @@ import { loadCapability } from "../discovery";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import type { Theme } from "../modes/theme/theme";
11
11
  import sshDescriptionBase from "../prompts/tools/ssh.md" with { type: "text" };
12
- import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
12
+ import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output";
13
13
  import type { SSHHostInfo } from "../ssh/connection-manager";
14
14
  import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager";
15
15
  import { executeSSH } from "../ssh/ssh-executor";
@@ -25,7 +25,7 @@ const sshSchema = Type.Object({
25
25
  host: Type.String({ description: "Host name from managed SSH config or discovered ssh.json files" }),
26
26
  command: Type.String({ description: "Command to execute on the remote host" }),
27
27
  cwd: Type.Optional(Type.String({ description: "Remote working directory (optional)" })),
28
- timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 60)" })),
28
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds", default: 60 })),
29
29
  });
30
30
 
31
31
  export interface SSHToolDetails {
@@ -168,15 +168,7 @@ export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
168
168
  compatEnabled: hostInfo.compatEnabled,
169
169
  artifactPath,
170
170
  artifactId,
171
- onChunk: chunk => {
172
- tailBuffer.append(chunk);
173
- if (onUpdate) {
174
- onUpdate({
175
- content: [{ type: "text", text: tailBuffer.text() }],
176
- details: {},
177
- });
178
- }
179
- },
171
+ onChunk: streamTailUpdates(tailBuffer, onUpdate),
180
172
  });
181
173
 
182
174
  if (result.cancelled) {
@@ -10,7 +10,7 @@ import { Type } from "@sinclair/typebox";
10
10
  import Ajv, { type ErrorObject, type ValidateFunction } from "ajv";
11
11
  import { subprocessToolRegistry } from "../task/subprocess-tool-registry";
12
12
  import type { ToolSession } from ".";
13
- import { jtdToJsonSchema } from "./jtd-to-json-schema";
13
+ import { jtdToJsonSchema, normalizeSchema } from "./jtd-to-json-schema";
14
14
 
15
15
  export interface SubmitResultDetails {
16
16
  data: unknown;
@@ -20,18 +20,6 @@ export interface SubmitResultDetails {
20
20
 
21
21
  const ajv = new Ajv({ allErrors: true, strict: false, logger: false });
22
22
 
23
- function normalizeSchema(schema: unknown): { normalized?: unknown; error?: string } {
24
- if (schema === undefined || schema === null) return {};
25
- if (typeof schema === "string") {
26
- try {
27
- return { normalized: JSON.parse(schema) };
28
- } catch (err) {
29
- return { error: err instanceof Error ? err.message : String(err) };
30
- }
31
- }
32
- return { normalized: schema };
33
- }
34
-
35
23
  function formatSchema(schema: unknown): string {
36
24
  if (schema === undefined) return "No schema provided.";
37
25
  if (typeof schema === "string") return schema;