@oh-my-pi/pi-coding-agent 15.5.4 → 15.5.6

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.
package/CHANGELOG.md CHANGED
@@ -2,10 +2,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.6] - 2026-05-27
6
+ ### Added
7
+
8
+ - Support for multi-range line selectors on URLs (e.g., `:5-10,20-30`) to fetch and display multiple non-contiguous sections
9
+ - Support for combining `:raw` mode with line range selectors on URLs (e.g., `:raw:1-120` or `:1-120:raw`)
10
+ - Support for line range selectors on directory listings (e.g., `:30-40` to view lines 30–40 of a directory tree)
11
+ - Clear error message when requesting a line offset beyond the end of a directory listing
12
+
13
+ ### Changed
14
+
15
+ - URL selector parsing now supports multiple trailing selector tokens (e.g., `:raw:N-M`), applying them left-to-right
16
+
17
+ ### Fixed
18
+
19
+ - Fixed `:raw` selector being ignored for JSON and feed URLs, causing them to be pretty-printed or converted to markdown instead of returning raw content
20
+ - Fixed directory listing line selectors silently dropping the offset parameter and only applying the limit
21
+
22
+ ## [15.5.5] - 2026-05-27
23
+
24
+ ### Changed
25
+
26
+ - Removed the model-facing `path` property from hashline edit tool parameters; hashline edit targets now come from `¶PATH` headers in `input`.
27
+
28
+ ### Fixed
29
+
30
+ - Fixed legacy pi-* extension loading regression where `import { Type } from "@(scope)/pi-ai"` (e.g. `@earendil-works/pi-ai` used by `@plannotator/pi-extension`) failed with `Export named 'Type' not found` after pi-ai 15.1.0 removed the root `Type` runtime export; the legacy-pi compat layer now redirects bare `@oh-my-pi/pi-ai` root imports through a sibling shim that re-exports the canonical pi-ai surface plus the Zod-backed `Type` runtime from the same TypeBox shim served to `@sinclair/typebox` imports ([#1437](https://github.com/can1357/oh-my-pi/issues/1437))
31
+
5
32
  ## [15.5.4] - 2026-05-27
6
33
 
7
34
  ### Breaking Changes
8
-
9
35
  - Removed the package root `hashline` export so imports from the top-level entrypoint can no longer access `hashline` helpers directly
10
36
 
11
37
  ### Added
@@ -12,6 +12,12 @@
12
12
  import { type PatchSection } from "@oh-my-pi/hashline";
13
13
  export interface HashlineDiffOptions {
14
14
  autoDropPureInsertDuplicates?: boolean;
15
+ /**
16
+ * Use the streaming-tolerant applier ({@link PatchSection.applyPartialTo})
17
+ * so trailing in-flight ops do not throw or emit phantom edits. Streaming
18
+ * preview path only.
19
+ */
20
+ streaming?: boolean;
15
21
  }
16
22
  export declare function computeHashlineSectionDiff(section: PatchSection, cwd: string, options?: HashlineDiffOptions): Promise<{
17
23
  diff: string;
@@ -21,7 +27,6 @@ export declare function computeHashlineSectionDiff(section: PatchSection, cwd: s
21
27
  }>;
22
28
  export declare function computeHashlineDiff(input: {
23
29
  input: string;
24
- path?: string;
25
30
  }, cwd: string, options?: HashlineDiffOptions): Promise<{
26
31
  diff: string;
27
32
  firstChangedLine: number | undefined;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Coding-agent runner that drives the hashline {@link Patcher} on behalf of
3
- * the `edit` tool. Converts a `{input, path?}` tool-call payload into a
3
+ * the `edit` tool. Converts a `{input}` tool-call payload into a
4
4
  * fully-applied patch, wraps the result in the agent's
5
5
  * {@link AgentToolResult} shape, and attaches LSP diagnostics + `outputMeta`
6
6
  * for the renderer.
@@ -19,7 +19,6 @@ import { type HashlineParams, hashlineEditParamsSchema } from "./params";
19
19
  export interface ExecuteHashlineSingleOptions {
20
20
  session: ToolSession;
21
21
  input: string;
22
- path?: string;
23
22
  signal?: AbortSignal;
24
23
  batchRequest?: LspBatchRequest;
25
24
  writethrough: WritethroughCallback;
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * Zod schema for the `edit` tool's hashline mode payload. The schema is
3
3
  * deliberately permissive (`.passthrough()`) so providers can attach extra
4
- * keys without rejection; only `input` is required and `path` is an
5
- * optional fallback used when the input lacks a `¶PATH#HASH` header.
4
+ * keys without rejection; only `input` is required. `_input` is accepted as a
5
+ * provider-emitted alias for `input`.
6
6
  */
7
7
  import * as z from "zod/v4";
8
- export declare const hashlineEditParamsSchema: z.ZodObject<{
8
+ export declare const hashlineEditParamsSchema: z.ZodPreprocess<z.ZodObject<{
9
9
  input: z.ZodString;
10
- path: z.ZodOptional<z.ZodString>;
11
- }, z.core.$loose>;
10
+ }, z.core.$loose>>;
12
11
  export type HashlineParams = z.infer<typeof hashlineEditParamsSchema>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Compatibility shim for legacy extensions importing the package root of
3
+ * `@oh-my-pi/pi-ai` (or one of its aliased scopes like `@earendil-works/pi-ai`
4
+ * or `@mariozechner/pi-ai`).
5
+ *
6
+ * pi-ai 15.1.0 removed the historical TypeBox root exports (`Type`, plus the
7
+ * runtime-relevant half of the `Static`/`TSchema` pair) from the package
8
+ * entrypoint. Legacy extensions still author parameter schemas as
9
+ * `Type.Object({ ... })`, so this file is served by `legacy-pi-compat.ts` in
10
+ * place of the real pi-ai entrypoint whenever a legacy extension imports the
11
+ * bare package root. Subpath imports (`@oh-my-pi/pi-ai/utils/oauth`, etc.)
12
+ * continue to resolve directly against the bundled pi-ai package.
13
+ *
14
+ * The `Type` runtime is borrowed from the Zod-backed TypeBox shim that
15
+ * already serves bare `@sinclair/typebox` imports for the same extension
16
+ * class, keeping the legacy-compat surface internally consistent.
17
+ *
18
+ * Type-level `Static` and `TSchema` continue to come from pi-ai's own
19
+ * `types.ts` via the `export *` below — pi-ai still exports both as types,
20
+ * only the runtime `Type` builder was removed.
21
+ */
22
+ export * from "@oh-my-pi/pi-ai";
23
+ export { Type } from "./typebox";
@@ -4,12 +4,15 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
4
4
  import { type Theme } from "../modes/theme/theme";
5
5
  import type { ToolSession } from "../sdk";
6
6
  import { type OutputMeta } from "./output-meta";
7
+ import { type LineRange } from "./path-utils";
7
8
  export declare function isReadableUrlPath(value: string): boolean;
8
9
  export interface ParsedReadUrlTarget {
9
10
  path: string;
10
11
  raw: boolean;
11
12
  offset?: number;
12
13
  limit?: number;
14
+ /** Populated only when the selector carries 2+ ranges. Single-range stays on offset/limit. */
15
+ ranges?: readonly LineRange[];
13
16
  }
14
17
  export declare function parseReadUrlTarget(readPath: string): ParsedReadUrlTarget | null;
15
18
  interface FetchImagePayload {
@@ -23,6 +23,13 @@ export type FindToolInput = z.infer<typeof findSchema>;
23
23
  * must pass through.
24
24
  */
25
25
  export declare function validateFindPathInputs(paths: readonly string[]): void;
26
+ /**
27
+ * Group find matches by their directory so the model doesn't pay repeated
28
+ * tokens for shared path prefixes. Preserves the input order: groups appear in
29
+ * the order their first member was emitted (mtime-desc for native glob), and
30
+ * within a group entries keep their relative order.
31
+ */
32
+ export declare function formatFindGroupedOutput(paths: readonly string[]): string;
26
33
  export interface FindToolDetails {
27
34
  truncation?: TruncationResult;
28
35
  resultLimitReached?: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.5.4",
4
+ "version": "15.5.6",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,13 +47,13 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "15.5.4",
51
- "@oh-my-pi/omp-stats": "15.5.4",
52
- "@oh-my-pi/pi-agent-core": "15.5.4",
53
- "@oh-my-pi/pi-ai": "15.5.4",
54
- "@oh-my-pi/pi-natives": "15.5.4",
55
- "@oh-my-pi/pi-tui": "15.5.4",
56
- "@oh-my-pi/pi-utils": "15.5.4",
50
+ "@oh-my-pi/hashline": "15.5.6",
51
+ "@oh-my-pi/omp-stats": "15.5.6",
52
+ "@oh-my-pi/pi-agent-core": "15.5.6",
53
+ "@oh-my-pi/pi-ai": "15.5.6",
54
+ "@oh-my-pi/pi-natives": "15.5.6",
55
+ "@oh-my-pi/pi-tui": "15.5.6",
56
+ "@oh-my-pi/pi-utils": "15.5.6",
57
57
  "@puppeteer/browsers": "^2.13.0",
58
58
  "@types/turndown": "5.0.6",
59
59
  "@xterm/headless": "^6.0.0",
@@ -56,6 +56,17 @@ async function main(): Promise<void> {
56
56
  "../stats/src/sync-worker.ts",
57
57
  "./src/tools/browser/tab-worker-entry.ts",
58
58
  "./src/eval/js/worker-entry.ts",
59
+ // Legacy pi-* extension compat shims served by `legacy-pi-compat.ts`.
60
+ // Both are reached only via the computed `TYPEBOX_SHIM_PATH` /
61
+ // `LEGACY_PI_AI_SHIM_PATH` constants (which `--compile`'s static
62
+ // analyzer cannot trace), so each shim must be listed here to land
63
+ // in bunfs alongside the workers above. The bunfs entry path is
64
+ // `--root`-relative with a `.js` extension, e.g.
65
+ // `/$bunfs/root/packages/coding-agent/src/extensibility/typebox.js`,
66
+ // which is what the `isCompiledBinary()` branch in
67
+ // `legacy-pi-compat.ts` resolves to at runtime.
68
+ "./src/extensibility/typebox.ts",
69
+ "./src/extensibility/legacy-pi-ai-shim.ts",
59
70
  "--outfile",
60
71
  "dist/omp",
61
72
  ],
package/src/edit/diff.ts CHANGED
@@ -58,7 +58,7 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten
58
58
  * Generate a unified diff string with line numbers and context.
59
59
  * Returns both the diff string and the first changed line number (in the new file).
60
60
  */
61
- export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): DiffResult {
61
+ export function generateDiffString(oldContent: string, newContent: string, contextLines = 2): DiffResult {
62
62
  const parts = Diff.diffLines(oldContent, newContent);
63
63
  const output: string[] = [];
64
64
 
@@ -119,8 +119,9 @@ export function generateDiffString(oldContent: string, newContent: string, conte
119
119
  linesToShow = raw.slice(0, contextLimit);
120
120
  }
121
121
 
122
+ // Leading-skip placeholder is omitted: the first emitted line's
123
+ // number already conveys that earlier lines were trimmed.
122
124
  if (leadingSkip > 0) {
123
- output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
124
125
  oldLineNum += leadingSkip;
125
126
  newLineNum += leadingSkip;
126
127
  }
@@ -143,8 +144,9 @@ export function generateDiffString(oldContent: string, newContent: string, conte
143
144
  }
144
145
  }
145
146
 
147
+ // Trailing-skip placeholder is omitted for the same reason: the
148
+ // final emitted line's number tells the reader the file continues.
146
149
  if (trailingSkip > 0) {
147
- output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
148
150
  oldLineNum += trailingSkip;
149
151
  newLineNum += trailingSkip;
150
152
  }
@@ -10,7 +10,6 @@
10
10
  * and no auto-generated-file refusal — those belong on the write path.
11
11
  */
12
12
  import {
13
- applyEdits,
14
13
  computeFileHash,
15
14
  Patch as HashlinePatch,
16
15
  normalizeToLF,
@@ -24,6 +23,12 @@ import { readEditFileText } from "../read-file";
24
23
 
25
24
  export interface HashlineDiffOptions {
26
25
  autoDropPureInsertDuplicates?: boolean;
26
+ /**
27
+ * Use the streaming-tolerant applier ({@link PatchSection.applyPartialTo})
28
+ * so trailing in-flight ops do not throw or emit phantom edits. Streaming
29
+ * preview path only.
30
+ */
31
+ streaming?: boolean;
27
32
  }
28
33
 
29
34
  async function readSectionText(absolutePath: string, sectionPath: string): Promise<string> {
@@ -62,7 +67,9 @@ export async function computeHashlineSectionDiff(
62
67
  const normalized = normalizeToLF(content);
63
68
  const hashError = validateSectionHash(section, normalized);
64
69
  if (hashError) return { error: hashError };
65
- const result = applyEdits(normalized, [...section.edits], options);
70
+ const result = options.streaming
71
+ ? section.applyPartialTo(normalized, options)
72
+ : section.applyTo(normalized, options);
66
73
  if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
67
74
  return generateDiffString(normalized, result.text);
68
75
  } catch (err) {
@@ -71,13 +78,13 @@ export async function computeHashlineSectionDiff(
71
78
  }
72
79
 
73
80
  export async function computeHashlineDiff(
74
- input: { input: string; path?: string },
81
+ input: { input: string },
75
82
  cwd: string,
76
83
  options: HashlineDiffOptions = {},
77
84
  ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
78
85
  let patch: Patch;
79
86
  try {
80
- patch = HashlinePatch.parse(input.input, { cwd, path: input.path });
87
+ patch = HashlinePatch.parse(input.input, { cwd });
81
88
  } catch (err) {
82
89
  return { error: err instanceof Error ? err.message : String(err) };
83
90
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Coding-agent runner that drives the hashline {@link Patcher} on behalf of
3
- * the `edit` tool. Converts a `{input, path?}` tool-call payload into a
3
+ * the `edit` tool. Converts a `{input}` tool-call payload into a
4
4
  * fully-applied patch, wraps the result in the agent's
5
5
  * {@link AgentToolResult} shape, and attaches LSP diagnostics + `outputMeta`
6
6
  * for the renderer.
@@ -31,7 +31,6 @@ import { type HashlineParams, hashlineEditParamsSchema } from "./params";
31
31
  export interface ExecuteHashlineSingleOptions {
32
32
  session: ToolSession;
33
33
  input: string;
34
- path?: string;
35
34
  signal?: AbortSignal;
36
35
  batchRequest?: LspBatchRequest;
37
36
  writethrough: WritethroughCallback;
@@ -91,16 +90,10 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
91
90
 
92
91
  const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
93
92
  const previewBlock = preview.preview ? `\n${preview.preview}` : "";
94
- const headline = preview.preview
95
- ? `${result.path}:`
96
- : result.op === "create"
97
- ? `Created ${result.path}`
98
- : `Updated ${result.path}`;
99
-
100
93
  const firstChangedLine = result.firstChangedLine ?? diff.firstChangedLine;
101
94
  return {
102
95
  toolResult: {
103
- content: [{ type: "text", text: `${headline}\n${result.header}${previewBlock}${warningsBlock}` }],
96
+ content: [{ type: "text", text: `${result.header}${previewBlock}${warningsBlock}` }],
104
97
  details: {
105
98
  diff: diff.diff,
106
99
  firstChangedLine,
@@ -122,7 +115,7 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
122
115
  export async function executeHashlineSingle(
123
116
  options: ExecuteHashlineSingleOptions,
124
117
  ): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
125
- const patch = Patch.parse(options.input, { cwd: options.session.cwd, path: options.path });
118
+ const patch = Patch.parse(options.input, { cwd: options.session.cwd });
126
119
  if (patch.sections.length === 0) {
127
120
  throw new Error("No hashline sections found in input.");
128
121
  }
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * Zod schema for the `edit` tool's hashline mode payload. The schema is
3
3
  * deliberately permissive (`.passthrough()`) so providers can attach extra
4
- * keys without rejection; only `input` is required and `path` is an
5
- * optional fallback used when the input lacks a `¶PATH#HASH` header.
4
+ * keys without rejection; only `input` is required. `_input` is accepted as a
5
+ * provider-emitted alias for `input`.
6
6
  */
7
7
  import * as z from "zod/v4";
8
8
 
9
- export const hashlineEditParamsSchema = z.object({ input: z.string(), path: z.string().optional() }).passthrough();
9
+ export const hashlineEditParamsSchema = z.preprocess(raw => {
10
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
11
+
12
+ const record = raw as Record<string, unknown>;
13
+ if (typeof record.input === "string" || typeof record._input !== "string") return raw;
14
+
15
+ return { ...record, input: record._input };
16
+ }, z.object({ input: z.string() }).passthrough());
10
17
 
11
18
  export type HashlineParams = z.infer<typeof hashlineEditParamsSchema>;
package/src/edit/index.ts CHANGED
@@ -262,19 +262,17 @@ async function executeSinglePathEntries(
262
262
 
263
263
  function extractApprovalPath(args: unknown): string {
264
264
  const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
265
- const targetPath = record.path;
266
- if (typeof targetPath === "string" && targetPath.length > 0) {
267
- return targetPath;
268
- }
269
-
270
265
  const input = typeof record.input === "string" ? record.input : undefined;
271
- if (!input) return "(unknown)";
266
+ if (input) {
267
+ const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
268
+ if (hashlineMatch?.[1]) return hashlineMatch[1];
272
269
 
273
- const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
274
- if (hashlineMatch?.[1]) return hashlineMatch[1];
270
+ const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
271
+ if (applyPatchMatch?.[1]) return applyPatchMatch[1].trim();
272
+ }
275
273
 
276
- const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
277
- return applyPatchMatch?.[1]?.trim() || "(unknown)";
274
+ const targetPath = record.path;
275
+ return typeof targetPath === "string" && targetPath.length > 0 ? targetPath : "(unknown)";
278
276
  }
279
277
 
280
278
  export class EditTool implements AgentTool<TInput> {
@@ -430,11 +428,10 @@ export class EditTool implements AgentTool<TInput> {
430
428
  batchRequest: LspBatchRequest | undefined,
431
429
  _onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
432
430
  ) => {
433
- const { input, path } = params as HashlineParams & { path?: string };
431
+ const { input } = params as HashlineParams;
434
432
  return executeHashlineSingle({
435
433
  session: tool.session,
436
434
  input,
437
- path,
438
435
  signal,
439
436
  batchRequest,
440
437
  writethrough: tool.#writethrough,
@@ -235,14 +235,21 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string)
235
235
 
236
236
  function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
237
237
  if (!diff) return "";
238
- const lines = diff.split("\n");
239
- const total = lines.length;
240
- const displayLines = lines.slice(-EDIT_STREAMING_PREVIEW_LINES);
241
- const hidden = total - displayLines.length;
238
+ // Hunk-aware truncation keeps the change rows themselves visible and
239
+ // trims surrounding context proportionally so a multi-hunk diff doesn't
240
+ // turn into just the tail of the last hunk while streaming.
241
+ const {
242
+ text: truncatedDiff,
243
+ hiddenHunks,
244
+ hiddenLines,
245
+ } = truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, EDIT_STREAMING_PREVIEW_LINES);
242
246
  let text = "\n\n";
243
- text += renderDiffColored(displayLines.join("\n"), { filePath: rawPath });
244
- if (hidden > 0) {
245
- text += uiTheme.fg("dim", `\n… (${label} +${hidden} lines)`);
247
+ text += renderDiffColored(truncatedDiff, { filePath: rawPath });
248
+ if (hiddenHunks > 0 || hiddenLines > 0) {
249
+ const remainder: string[] = [];
250
+ if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
251
+ if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
252
+ text += uiTheme.fg("dim", `\n… (${label} +${remainder.join(", ")})`);
246
253
  } else {
247
254
  text += uiTheme.fg("dim", `\n(${label})`);
248
255
  }
@@ -20,11 +20,8 @@ import {
20
20
  END_PATCH_MARKER,
21
21
  type PatchSection as HashlineInputSection,
22
22
  Patch as HashlinePatch,
23
- Tokenizer as HashlineTokenizer,
24
23
  } from "@oh-my-pi/hashline";
25
- import { sanitizeText } from "@oh-my-pi/pi-utils";
26
24
  import type { Theme } from "../modes/theme/theme";
27
- import { replaceTabs, truncateToWidth } from "../tools/render-utils";
28
25
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
29
26
  import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
30
27
  import { computeHashlineDiff, computeHashlineSectionDiff } from "./hashline/diff";
@@ -73,48 +70,6 @@ export interface EditStreamingStrategy<Args = unknown> {
73
70
  renderStreamingFallback(args: Args, uiTheme: Theme): string;
74
71
  }
75
72
 
76
- const STREAMING_FALLBACK_LINES = 12;
77
- const STREAMING_FALLBACK_WIDTH = 80;
78
-
79
- // Streaming-preview classification reuses one tokenizer instance for the
80
- // stateless predicates and `tokenize`/`tokenizeAll` helpers; instances are
81
- // cheap, but keeping a single module-level reference matches the rest of
82
- // the hashline package.
83
- const HASHLINE_TOKENIZER = new HashlineTokenizer();
84
-
85
- function trimHashlineStreamingSyntax(lines: string[]): string[] {
86
- let index = lines.findIndex(line => line.trim().length > 0);
87
- if (index === -1) return [];
88
-
89
- if (HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "envelope-begin") {
90
- index++;
91
- while (index < lines.length && lines[index].trim().length === 0) index++;
92
- }
93
- if (index < lines.length && HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "header") {
94
- index++;
95
- }
96
-
97
- return lines.slice(index).filter(line => !HASHLINE_TOKENIZER.isEnvelopeMarker(line));
98
- }
99
-
100
- function renderHashlineInputFallback(input: string, uiTheme: Theme): string {
101
- const lines = trimHashlineStreamingSyntax(sanitizeText(input).split("\n"));
102
- if (!lines.some(line => line.trim().length > 0)) return "";
103
-
104
- const displayLines = lines.slice(-STREAMING_FALLBACK_LINES);
105
- const hidden = lines.length - displayLines.length;
106
- let text = "\n\n";
107
- text += displayLines
108
- .map(line => uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), STREAMING_FALLBACK_WIDTH)))
109
- .join("\n");
110
- if (hidden > 0) {
111
- text += uiTheme.fg("dim", `\n… (streaming +${hidden} lines)`);
112
- } else {
113
- text += uiTheme.fg("dim", "\n(streaming)");
114
- }
115
- return text;
116
- }
117
-
118
73
  // -----------------------------------------------------------------------------
119
74
  // Partial-JSON handling
120
75
  // -----------------------------------------------------------------------------
@@ -273,7 +228,6 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
273
228
 
274
229
  interface HashlineArgs {
275
230
  input?: string;
276
- path?: string;
277
231
  __partialJson?: string;
278
232
  }
279
233
 
@@ -353,75 +307,6 @@ function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[
353
307
  return previews.length > 0 ? previews : null;
354
308
  }
355
309
 
356
- /**
357
- * Hashline equivalent: emit each payload line as a `+added` line in the
358
- * order the model typed it. We deliberately omit op headers and removal
359
- * targets from the streaming preview because their content lives in the file
360
- * and would require a costly re-apply per tick; the complete unified diff is
361
- * shown once streaming finishes.
362
- */
363
- function buildHashlineNaturalOrderPreviews(
364
- input: string,
365
- defaultPath: string | undefined,
366
- ): PerFileDiffPreview[] | null {
367
- const groups = new Map<string, string[]>();
368
- let currentPath = defaultPath ?? "";
369
- const ensure = (sectionPath: string): string[] => {
370
- let bucket = groups.get(sectionPath);
371
- if (!bucket) {
372
- bucket = [];
373
- groups.set(sectionPath, bucket);
374
- }
375
- return bucket;
376
- };
377
-
378
- // Per-call instance: the streaming preview re-runs each tick with the
379
- // cumulative input, and we need the line counter to start at 1. A
380
- // dedicated tokenizer keeps the shared HASHLINE_TOKENIZER above free
381
- // for stateless predicate use elsewhere in this module.
382
- const streamer = new HashlineTokenizer();
383
- for (const token of streamer.tokenizeAll(input)) {
384
- switch (token.kind) {
385
- case "envelope-begin":
386
- case "envelope-end":
387
- case "abort":
388
- case "op-delete":
389
- continue;
390
- case "blank":
391
- case "raw":
392
- continue;
393
- case "header":
394
- currentPath = token.path;
395
- if (currentPath) ensure(currentPath);
396
- continue;
397
- case "op-insert":
398
- case "op-replace":
399
- // Inline body on the op line itself (`N↓payload`, `A-B:payload`) is
400
- // payload content that just happens to share a line with the op
401
- // header — render it the same as a standalone payload token so
402
- // the very first character the model types after the sigil shows
403
- // up in the streaming preview. Without this, the preview is
404
- // empty until a newline arrives, and the renderer falls back to
405
- // raw input ("A-B: bla bla bla") instead of "+ bla bla bla".
406
- if (!currentPath || token.inlineBody === undefined) continue;
407
- ensure(currentPath).push(`+${token.inlineBody}`);
408
- continue;
409
- case "payload":
410
- if (!currentPath) continue;
411
- ensure(currentPath).push(`+${token.text}`);
412
- continue;
413
- }
414
- }
415
-
416
- if (groups.size === 0) return null;
417
- const previews: PerFileDiffPreview[] = [];
418
- for (const [sectionPath, body] of groups) {
419
- if (body.length === 0) continue;
420
- previews.push({ path: sectionPath, diff: body.join("\n") });
421
- }
422
- return previews.length > 0 ? previews : null;
423
- }
424
-
425
310
  const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
426
311
  extractCompleteEdits(args) {
427
312
  return args;
@@ -430,25 +315,21 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
430
315
  if (typeof args.input !== "string" || args.input.length === 0) return null;
431
316
  const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
432
317
  if (input.length === 0) return null;
433
- if (ctx.isStreaming) {
434
- // Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
435
- // reordering by showing payload lines in input order.
436
- return buildHashlineNaturalOrderPreviews(input, args.path);
437
- }
438
318
  ctx.signal.throwIfAborted();
439
319
 
440
320
  let sections: readonly HashlineInputSection[];
441
321
  try {
442
- sections = HashlinePatch.parse(input, { cwd: ctx.cwd, path: args.path }).sections;
322
+ sections = HashlinePatch.parse(input, { cwd: ctx.cwd }).sections;
443
323
  } catch {
444
- // Single-section fallback keeps the original error rendering for the
445
- // "haven't typed PATH` yet" case.
446
- const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
324
+ // While streaming, the trailing op may still be mid-typed and fail
325
+ // to parse; suppress until the next chunk arrives. Once args are
326
+ // complete, surface the error so the model sees what went wrong.
327
+ if (ctx.isStreaming) return null;
328
+ const result = await computeHashlineDiff({ input }, ctx.cwd, {
447
329
  autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
448
330
  });
449
331
  ctx.signal.throwIfAborted();
450
- if ("error" in result && !args.path) return [{ path: "", error: result.error }];
451
- return [toPerFilePreview(args.path ?? "", result)];
332
+ return [toPerFilePreview("", result)];
452
333
  }
453
334
  if (sections.length === 0) return null;
454
335
 
@@ -467,6 +348,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
467
348
  const section = sectionsToProcess[i];
468
349
  const result = await computeHashlineSectionDiff(section, ctx.cwd, {
469
350
  autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
351
+ streaming: ctx.isStreaming,
470
352
  });
471
353
  ctx.signal.throwIfAborted();
472
354
  // In a multi-section preview, ignore parse/apply errors from the
@@ -479,8 +361,13 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
479
361
  }
480
362
  return previews.length > 0 ? previews : null;
481
363
  },
482
- renderStreamingFallback(args, uiTheme) {
483
- return typeof args.input === "string" ? renderHashlineInputFallback(args.input, uiTheme) : "";
364
+ renderStreamingFallback() {
365
+ // Never leak raw hashline syntax (`64:`, `|payload`, `¶path#hash`)
366
+ // to the user — the streaming preview already projects every
367
+ // parseable op onto the real file via applyPartialTo, and an
368
+ // unparseable trailing chunk renders as "no preview yet" rather
369
+ // than a sigil dump.
370
+ return "";
484
371
  },
485
372
  };
486
373
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Compatibility shim for legacy extensions importing the package root of
3
+ * `@oh-my-pi/pi-ai` (or one of its aliased scopes like `@earendil-works/pi-ai`
4
+ * or `@mariozechner/pi-ai`).
5
+ *
6
+ * pi-ai 15.1.0 removed the historical TypeBox root exports (`Type`, plus the
7
+ * runtime-relevant half of the `Static`/`TSchema` pair) from the package
8
+ * entrypoint. Legacy extensions still author parameter schemas as
9
+ * `Type.Object({ ... })`, so this file is served by `legacy-pi-compat.ts` in
10
+ * place of the real pi-ai entrypoint whenever a legacy extension imports the
11
+ * bare package root. Subpath imports (`@oh-my-pi/pi-ai/utils/oauth`, etc.)
12
+ * continue to resolve directly against the bundled pi-ai package.
13
+ *
14
+ * The `Type` runtime is borrowed from the Zod-backed TypeBox shim that
15
+ * already serves bare `@sinclair/typebox` imports for the same extension
16
+ * class, keeping the legacy-compat surface internally consistent.
17
+ *
18
+ * Type-level `Static` and `TSchema` continue to come from pi-ai's own
19
+ * `types.ts` via the `export *` below — pi-ai still exports both as types,
20
+ * only the runtime `Type` builder was removed.
21
+ */
22
+
23
+ export * from "@oh-my-pi/pi-ai";
24
+ export { Type } from "./typebox";
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
+ import { isCompiledBinary } from "@oh-my-pi/pi-utils";
5
6
 
6
7
  // Canonical scope for in-process pi packages. Plugins published against any of
7
8
  // the aliased scopes below (mariozechner's original publish, earendil-works'
@@ -56,7 +57,34 @@ const resolvedSpecifierFallbacks = new Map<string, string>();
56
57
  // relying on them must vendor `@sinclair/typebox` directly.
57
58
  const TYPEBOX_SPECIFIER = "@sinclair/typebox";
58
59
  const TYPEBOX_SPECIFIER_FILTER = /^@sinclair\/typebox$/;
59
- const TYPEBOX_SHIM_PATH = path.resolve(import.meta.dir, "../typebox.ts");
60
+
61
+ // In-process compat shim paths. In dev `import.meta.dir` is the source folder of
62
+ // this file, so the dev branches resolve to the real `.ts` source. In compiled
63
+ // binaries `import.meta.dir` collapses to `/$bunfs/root`, so the runtime cannot
64
+ // recover the source layout that way; instead, each shim file is registered as
65
+ // a `--compile` entrypoint in `scripts/build-binary.ts`, which Bun emits into
66
+ // bunfs at a deterministic `--root`-relative path with a `.js` extension. The
67
+ // literals below must stay in sync with that listing — if either path drifts,
68
+ // every legacy plugin loading the shim fails with a missing-module error in
69
+ // release builds (without affecting `bun test`/dev).
70
+ const TYPEBOX_SHIM_PATH = isCompiledBinary()
71
+ ? "/$bunfs/root/packages/coding-agent/src/extensibility/typebox.js"
72
+ : path.resolve(import.meta.dir, "../typebox.ts");
73
+
74
+ // Legacy extensions historically imported `Type` (and `Static`/`TSchema`) from
75
+ // the package root of `@(scope)/pi-ai`. pi-ai 15.1.0 removed the runtime `Type`
76
+ // export (see `packages/ai/CHANGELOG.md`), so the bare canonical specifier no
77
+ // longer satisfies those imports. The override below redirects only the bare
78
+ // pi-ai package root onto a sibling shim that re-exports the canonical surface
79
+ // plus the borrowed `Type` runtime from the Zod-backed TypeBox shim. Subpath
80
+ // imports such as `@oh-my-pi/pi-ai/utils/oauth` continue to resolve directly
81
+ // against the bundled pi-ai package.
82
+ const LEGACY_PI_AI_SHIM_PATH = isCompiledBinary()
83
+ ? "/$bunfs/root/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.js"
84
+ : path.resolve(import.meta.dir, "../legacy-pi-ai-shim.ts");
85
+ const LEGACY_PI_PACKAGE_ROOT_OVERRIDES: Record<string, string> = {
86
+ [`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
87
+ };
60
88
 
61
89
  let isLegacyPiSpecifierShimInstalled = false;
62
90
 
@@ -85,6 +113,22 @@ function getResolvedSpecifier(specifier: string): string {
85
113
  return resolved;
86
114
  }
87
115
 
116
+ /**
117
+ * Resolve a canonical `@oh-my-pi/*` specifier to a filesystem path, preferring
118
+ * a bundled compat shim when one is registered for the package root.
119
+ *
120
+ * Falls back to `getResolvedSpecifier` (which may throw under compiled binary
121
+ * mode); callers handle that the same way they would for non-overridden
122
+ * specifiers.
123
+ */
124
+ function resolveCanonicalPiSpecifier(remappedSpecifier: string): string {
125
+ const override = LEGACY_PI_PACKAGE_ROOT_OVERRIDES[remappedSpecifier];
126
+ if (override) {
127
+ return override;
128
+ }
129
+ return getResolvedSpecifier(remappedSpecifier);
130
+ }
131
+
88
132
  function toImportSpecifier(resolvedPath: string): string {
89
133
  return url.pathToFileURL(resolvedPath).href;
90
134
  }
@@ -99,7 +143,7 @@ function rewriteLegacyPiImports(source: string): string {
99
143
  }
100
144
 
101
145
  try {
102
- return `${prefix}${toImportSpecifier(getResolvedSpecifier(remappedSpecifier))}${suffix}`;
146
+ return `${prefix}${toImportSpecifier(resolveCanonicalPiSpecifier(remappedSpecifier))}${suffix}`;
103
147
  } catch {
104
148
  // Resolution failed — typically in compiled binary mode where
105
149
  // Bun.resolveSync cannot walk up from /$bunfs/root to find the
@@ -250,7 +294,7 @@ function resolveLegacyPiSpecifier(args: { path: string; importer: string }): { p
250
294
  // Primary: resolve the canonical @oh-my-pi/* specifier from the host binary
251
295
  // location. Works in dev mode and in source-link installs.
252
296
  try {
253
- return { path: getResolvedSpecifier(remappedSpecifier) };
297
+ return { path: resolveCanonicalPiSpecifier(remappedSpecifier) };
254
298
  } catch {
255
299
  // Fallback for compiled binary mode: the bundled packages live inside
256
300
  // /$bunfs/root and aren't reachable by filesystem resolution. Try the
package/src/main.ts CHANGED
@@ -9,6 +9,7 @@ import * as fs from "node:fs/promises";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
11
  import { createInterface } from "node:readline/promises";
12
+ import { keepaliveWhile } from "@oh-my-pi/pi-agent-core";
12
13
  import type { ImageContent } from "@oh-my-pi/pi-ai";
13
14
  import {
14
15
  $env,
@@ -315,7 +316,7 @@ async function runInteractiveMode(
315
316
  }
316
317
 
317
318
  while (true) {
318
- const input = await mode.getUserInput();
319
+ const input = await keepaliveWhile(mode.getUserInput());
319
320
  await submitInteractiveInput(mode, session, input);
320
321
  }
321
322
  }
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Spawns the agent in RPC mode and provides a typed API for all operations.
5
5
  */
6
+
7
+ import { isPromise } from "node:util/types";
6
8
  import type { AgentEvent, AgentMessage, AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
9
  import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
8
10
  import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
@@ -790,7 +792,7 @@ export class RpcClient {
790
792
  const stdin = this.#process.stdin as import("bun").FileSink;
791
793
  stdin.write(`${JSON.stringify(frame)}\n`);
792
794
  const flushResult = stdin.flush();
793
- if (flushResult instanceof Promise) {
795
+ if (isPromise(flushResult)) {
794
796
  flushResult.catch((err: Error) => {
795
797
  onError?.(err);
796
798
  });
@@ -5,17 +5,18 @@ Finds files and directories using fast pattern matching that works with any code
5
5
  - Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`), NEVER as a single comma-joined string (`paths: ["a,b"]` is rejected)
6
6
  - `gitignore` defaults to `true` and hides files matched by `.gitignore`. Set `gitignore: false` to find `.env*`, `*.log`, freshly-created build outputs, or anything else your repo ignores
7
7
  - `hidden` defaults to `true`; combine with `gitignore: false` to surface dotfiles that are also gitignored
8
+ - `limit` is clamped to 1-200 (default 200). Narrow the pattern instead of raising the limit
8
9
  - `timeout` is in seconds (default 5, clamped to 0.5–60). On timeout, find returns whatever partial matches it has collected with `truncated: true` and a notice — increase `timeout` or narrow the pattern instead of retrying blindly
9
10
  - You SHOULD perform multiple searches in parallel when potentially useful
10
11
  </instruction>
11
12
 
12
13
  <output>
13
- Matching file and directory paths sorted by modification time (most recent first). Directories are suffixed with `/`. Truncated at 1000 entries or 50KB (configurable via `limit`).
14
+ Matching file and directory paths sorted by modification time (most recent first), grouped by directory to reduce token usage. Each group starts with `# <dir>/` followed by basenames (one per line); directory entries get a trailing `/`. Root-level entries have no header. Truncated at 200 entries or 50KB.
14
15
  </output>
15
16
 
16
17
  <examples>
17
18
  # Find files
18
- `{"paths": ["src/**/*.ts"], "limit": 1000}`
19
+ `{"paths": ["src/**/*.ts"]}`
19
20
  # Multiple targets — separate array elements
20
21
  `{"paths": ["src/**/*.ts", "test/**/*.ts"]}`
21
22
  # Find gitignored files like .env
@@ -17,6 +17,7 @@ import * as crypto from "node:crypto";
17
17
  import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import { scheduler } from "node:timers/promises";
20
+ import { isPromise } from "node:util/types";
20
21
  import {
21
22
  type AfterToolCallContext,
22
23
  type AfterToolCallResult,
@@ -1307,10 +1308,25 @@ export class AgentSession {
1307
1308
 
1308
1309
  /** Emit an event to all listeners */
1309
1310
  #emit(event: AgentSessionEvent): void {
1310
- // Copy array before iteration to avoid mutation during iteration
1311
+ // Copy array before iteration to avoid mutation during iteration.
1311
1312
  const listeners = [...this.#eventListeners];
1312
1313
  for (const l of listeners) {
1313
- l(event);
1314
+ try {
1315
+ const result = l(event) as unknown;
1316
+ // Listener may be an async function whose returned Promise we don't await;
1317
+ // attach a catch so a rejection does not become an unhandled rejection.
1318
+ if (isPromise(result)) {
1319
+ result.catch(err => {
1320
+ logger.warn("AgentSession listener rejected", {
1321
+ error: err instanceof Error ? err.message : String(err),
1322
+ });
1323
+ });
1324
+ }
1325
+ } catch (err) {
1326
+ logger.warn("AgentSession listener threw", {
1327
+ error: err instanceof Error ? err.message : String(err),
1328
+ });
1329
+ }
1314
1330
  }
1315
1331
  }
1316
1332
 
@@ -6,6 +6,7 @@ import { htmlToMarkdown } from "@oh-my-pi/pi-natives";
6
6
  import { type Component, Text } from "@oh-my-pi/pi-tui";
7
7
  import { $which, ptree, truncate } from "@oh-my-pi/pi-utils";
8
8
  import { parseHTML } from "linkedom";
9
+ import { LRUCache } from "lru-cache/raw";
9
10
  import type { Settings } from "../config/settings";
10
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
12
  import { type Theme, theme } from "../modes/theme/theme";
@@ -23,6 +24,7 @@ import { finalizeOutput, loadPage, looksLikeHtml, MAX_OUTPUT_CHARS } from "../we
23
24
  import { convertWithMarkit, fetchBinary } from "../web/scrapers/utils";
24
25
  import { applyListLimit } from "./list-limit";
25
26
  import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
27
+ import { type LineRange, parseLineRanges } from "./path-utils";
26
28
  import { formatExpandHint, getDomain, replaceTabs } from "./render-utils";
27
29
  import { ToolAbortError, ToolError } from "./tool-errors";
28
30
  import { toolResult } from "./tool-result";
@@ -138,16 +140,31 @@ export function isReadableUrlPath(value: string): boolean {
138
140
  return /^https?:\/\//i.test(value) || /^www\./i.test(value);
139
141
  }
140
142
 
141
- // URL line selectors mirror the file form: `:50`, `:50-100`, `:50+150`, `:raw`.
142
- // If a URL would otherwise look like `host:port`, add a trailing slash before the selector
143
- // (e.g. `https://example.com/:80` to read line 80 of the document at `https://example.com/`).
144
- const URL_LINE_RANGE_RE = /^(\d+)(?:([-+])(\d+))?$/;
143
+ // URL line selectors mirror the file form: `:50`, `:50-100`, `:50+150`, `:5-10,20-30`, `:raw`,
144
+ // or `:raw:N-M` / `:N-M:raw` to combine raw mode with a range. If a URL would otherwise look
145
+ // like `host:port`, add a trailing slash before the selector (e.g. `https://example.com/:80`
146
+ // to read line 80 of the document at `https://example.com/`).
145
147
 
146
148
  export interface ParsedReadUrlTarget {
147
149
  path: string;
148
150
  raw: boolean;
149
151
  offset?: number;
150
152
  limit?: number;
153
+ /** Populated only when the selector carries 2+ ranges. Single-range stays on offset/limit. */
154
+ ranges?: readonly LineRange[];
155
+ }
156
+
157
+ /** Recognize a single selector token (`raw` or one/many line ranges). */
158
+ function isUrlSelectorToken(token: string): boolean {
159
+ if (token === "raw") return true;
160
+ try {
161
+ return parseLineRanges(token) !== null;
162
+ } catch {
163
+ // `parseLineRanges` throws `ToolError` for malformed ranges (e.g. `5+0`). Only treat the
164
+ // token as a selector when it parses cleanly so URL ports like `:80` keep flowing
165
+ // through to the URL path.
166
+ return false;
167
+ }
151
168
  }
152
169
 
153
170
  export function parseReadUrlTarget(readPath: string): ParsedReadUrlTarget | null {
@@ -157,62 +174,71 @@ export function parseReadUrlTarget(readPath: string): ParsedReadUrlTarget | null
157
174
  return null;
158
175
  }
159
176
 
160
- const selector = embedded?.sel;
161
- const raw = selector === "raw";
162
- const lineMatch = selector && selector !== "raw" ? URL_LINE_RANGE_RE.exec(selector) : null;
163
- if (lineMatch) {
164
- const startLine = Number.parseInt(lineMatch[1]!, 10);
165
- if (startLine < 1) {
166
- throw new ToolError("URL line selector 0 is invalid; lines are 1-indexed. Use :1.");
177
+ let raw = false;
178
+ let ranges: readonly LineRange[] | undefined;
179
+ for (const sel of embedded?.sels ?? []) {
180
+ if (sel === "raw") {
181
+ raw = true;
182
+ continue;
167
183
  }
168
- const sep = lineMatch[2];
169
- const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
170
- let endLine: number | undefined;
171
- if (sep === "+") {
172
- if (rhs === undefined || rhs < 1) {
173
- throw new ToolError(`Invalid range ${startLine}+${rhs ?? 0}: count must be >= 1.`);
174
- }
175
- endLine = startLine + rhs - 1;
176
- } else if (sep === "-") {
177
- if (rhs === undefined || rhs < startLine) {
178
- throw new ToolError(`Invalid range ${startLine}-${rhs ?? 0}: end must be >= start.`);
179
- }
180
- endLine = rhs;
184
+ if (ranges !== undefined) {
185
+ // Two range groups on the same URL (`…:5-10:20-30`) combine with commas instead.
186
+ throw new ToolError(
187
+ `URL selector has multiple range groups; combine them with commas (e.g. \`:5-10,20-30\`).`,
188
+ );
181
189
  }
190
+ const parsed = parseLineRanges(sel);
191
+ if (parsed === null) {
192
+ // Shouldn't happen — isUrlSelectorToken vetted it. Belt-and-suspenders.
193
+ throw new ToolError(`Invalid URL line selector: ${sel}`);
194
+ }
195
+ ranges = parsed;
196
+ }
197
+
198
+ if (!ranges || ranges.length === 0) return { path: urlPath, raw };
199
+ if (ranges.length === 1) {
200
+ const r = ranges[0];
182
201
  return {
183
202
  path: urlPath,
184
- raw: false,
185
- offset: startLine,
186
- limit: endLine !== undefined ? endLine - startLine + 1 : undefined,
203
+ raw,
204
+ offset: r.startLine,
205
+ limit: r.endLine !== undefined ? r.endLine - r.startLine + 1 : undefined,
187
206
  };
188
207
  }
189
-
190
- return { path: urlPath, raw };
208
+ return { path: urlPath, raw, ranges };
191
209
  }
192
210
 
193
- function tryExtractEmbeddedUrlSelector(readPath: string): { path: string; sel?: string } | null {
194
- const lastColonIndex = readPath.lastIndexOf(":");
195
- if (lastColonIndex <= 0) {
196
- return null;
197
- }
198
-
199
- const candidateSelector = readPath.slice(lastColonIndex + 1);
200
- const basePath = readPath.slice(0, lastColonIndex);
201
- if (!isReadableUrlPath(basePath)) {
202
- return null;
203
- }
211
+ /**
212
+ * Peel one or more selector tokens off the right of a URL string. Walks back through
213
+ * trailing `:tok` segments while each token (a) looks like a selector and (b) leaves
214
+ * behind a string that still parses as a URL. Returns selectors left-to-right so callers
215
+ * can apply them in source order.
216
+ */
217
+ function tryExtractEmbeddedUrlSelector(readPath: string): { path: string; sels: string[] } | null {
218
+ let basePath = readPath;
219
+ const sels: string[] = [];
220
+ while (true) {
221
+ const lastColonIndex = basePath.lastIndexOf(":");
222
+ if (lastColonIndex <= 0) break;
223
+
224
+ const candidate = basePath.slice(lastColonIndex + 1);
225
+ const remainder = basePath.slice(0, lastColonIndex);
226
+ if (!isReadableUrlPath(remainder)) break;
227
+ if (!isUrlSelectorToken(candidate)) break;
204
228
 
205
- const isEmbeddedSelector = candidateSelector === "raw" || URL_LINE_RANGE_RE.test(candidateSelector);
206
- if (!isEmbeddedSelector) {
207
- return null;
208
- }
229
+ try {
230
+ new URL(
231
+ remainder.startsWith("http://") || remainder.startsWith("https://") ? remainder : `https://${remainder}`,
232
+ );
233
+ } catch {
234
+ break;
235
+ }
209
236
 
210
- try {
211
- new URL(basePath.startsWith("http://") || basePath.startsWith("https://") ? basePath : `https://${basePath}`);
212
- return { path: basePath, sel: candidateSelector };
213
- } catch {
214
- return null;
237
+ sels.unshift(candidate);
238
+ basePath = remainder;
215
239
  }
240
+ if (sels.length === 0) return null;
241
+ return { path: basePath, sels };
216
242
  }
217
243
 
218
244
  /**
@@ -931,6 +957,22 @@ async function renderUrl(
931
957
  const isText = mime.includes("text/plain") || mime.includes("text/markdown");
932
958
  const isFeed = mime.includes("rss") || mime.includes("atom") || mime.includes("feed");
933
959
 
960
+ // Raw mode skips every text-shaping branch below (JSON pretty-print, feed-to-markdown,
961
+ // HTML extraction) and returns the response body verbatim. The image/markit branches
962
+ // above already ran because raw isn't useful for binary payloads.
963
+ if (raw) {
964
+ const output = finalizeOutput(rawContent);
965
+ return {
966
+ url,
967
+ finalUrl,
968
+ contentType: mime,
969
+ method: "raw",
970
+ content: output.content,
971
+ fetchedAt,
972
+ truncated: output.truncated,
973
+ notes,
974
+ };
975
+ }
934
976
  if (isJson) {
935
977
  const output = finalizeOutput(formatJson(rawContent));
936
978
  return {
@@ -1174,7 +1216,8 @@ interface ReadUrlCacheEntry {
1174
1216
  output: string;
1175
1217
  }
1176
1218
 
1177
- const readUrlCache = new Map<string, ReadUrlCacheEntry>();
1219
+ const READ_URL_CACHE_MAX_ENTRIES = 100;
1220
+ const readUrlCache = new LRUCache<string, ReadUrlCacheEntry>({ max: READ_URL_CACHE_MAX_ENTRIES });
1178
1221
 
1179
1222
  function getReadUrlCacheKey(session: ToolSession, requestedUrl: string, raw: boolean): string {
1180
1223
  const scope = session.getSessionFile() ?? session.cwd;
package/src/tools/find.ts CHANGED
@@ -39,14 +39,15 @@ const findSchema = z
39
39
  paths: z.array(z.string().describe("glob including search path")).min(1).describe("globs including search paths"),
40
40
  hidden: z.boolean().default(true).describe("include hidden files").optional(),
41
41
  gitignore: z.boolean().default(true).describe("respect gitignore").optional(),
42
- limit: z.number().default(1000).describe("max results").optional(),
42
+ limit: z.number().default(200).describe("max results (clamped to 1-200)").optional(),
43
43
  timeout: z.number().min(0.5).max(60).default(5).describe("timeout in seconds (0.5–60)").optional(),
44
44
  })
45
45
  .strict();
46
46
 
47
47
  export type FindToolInput = z.infer<typeof findSchema>;
48
48
 
49
- const DEFAULT_LIMIT = 1000;
49
+ const DEFAULT_LIMIT = 200;
50
+ const MAX_LIMIT = 200;
50
51
  const DEFAULT_GLOB_TIMEOUT_MS = 5000;
51
52
  const MIN_GLOB_TIMEOUT_MS = 500;
52
53
  const MAX_GLOB_TIMEOUT_MS = 60_000;
@@ -78,6 +79,37 @@ export function validateFindPathInputs(paths: readonly string[]): void {
78
79
  }
79
80
  }
80
81
 
82
+ /**
83
+ * Group find matches by their directory so the model doesn't pay repeated
84
+ * tokens for shared path prefixes. Preserves the input order: groups appear in
85
+ * the order their first member was emitted (mtime-desc for native glob), and
86
+ * within a group entries keep their relative order.
87
+ */
88
+ export function formatFindGroupedOutput(paths: readonly string[]): string {
89
+ if (paths.length === 0) return "";
90
+ const groups = new Map<string, string[]>();
91
+ for (const entry of paths) {
92
+ const hasTrailingSlash = entry.endsWith("/");
93
+ const trimmed = hasTrailingSlash ? entry.slice(0, -1) : entry;
94
+ const slash = trimmed.lastIndexOf("/");
95
+ const dir = slash === -1 ? "" : trimmed.slice(0, slash);
96
+ const base = slash === -1 ? trimmed : trimmed.slice(slash + 1);
97
+ const label = hasTrailingSlash ? `${base}/` : base;
98
+ const list = groups.get(dir);
99
+ if (list) list.push(label);
100
+ else groups.set(dir, [label]);
101
+ }
102
+ const sections: string[] = [];
103
+ for (const [dir, entries] of groups) {
104
+ if (dir === "") {
105
+ sections.push(entries.join("\n"));
106
+ } else {
107
+ sections.push(`# ${dir}/\n${entries.join("\n")}`);
108
+ }
109
+ }
110
+ return sections.join("\n\n");
111
+ }
112
+
81
113
  export interface FindToolDetails {
82
114
  truncation?: TruncationResult;
83
115
  resultLimitReached?: number;
@@ -195,11 +227,11 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
195
227
  if (searchPath === "/") {
196
228
  throw new ToolError("Searching from root directory '/' is not allowed");
197
229
  }
198
- const rawLimit = limit ?? DEFAULT_LIMIT;
199
- const effectiveLimit = Number.isFinite(rawLimit) ? Math.floor(rawLimit) : Number.NaN;
200
- if (!Number.isFinite(effectiveLimit) || effectiveLimit <= 0) {
230
+ const requestedLimit = limit ?? DEFAULT_LIMIT;
231
+ if (!Number.isFinite(requestedLimit) || requestedLimit <= 0) {
201
232
  throw new ToolError("Limit must be a positive number");
202
233
  }
234
+ const effectiveLimit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(requestedLimit)));
203
235
  const includeHidden = hidden ?? true;
204
236
  const useGitignore = gitignore ?? true;
205
237
  const requestedTimeoutMs = timeout != null ? Math.round(timeout * 1000) : DEFAULT_GLOB_TIMEOUT_MS;
@@ -241,7 +273,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
241
273
  const listLimit = applyListLimit(files, { limit: effectiveLimit });
242
274
  const limited = listLimit.items;
243
275
  const limitMeta = listLimit.meta;
244
- const baseOutput = limited.join("\n");
276
+ const baseOutput = formatFindGroupedOutput(limited);
245
277
  const trailingNotes: string[] = [];
246
278
  if (notice) trailingNotes.push(notice);
247
279
  if (missingPathsNote) trailingNotes.push(missingPathsNote);
package/src/tools/read.ts CHANGED
@@ -1488,6 +1488,21 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1488
1488
  if (!this.session.settings.get("fetch.enabled")) {
1489
1489
  throw new ToolError("URL reads are disabled by settings.");
1490
1490
  }
1491
+ if (parsedUrlTarget.ranges !== undefined) {
1492
+ const cached = await loadReadUrlCacheEntry(
1493
+ this.session,
1494
+ { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw },
1495
+ signal,
1496
+ { ensureArtifact: true, preferCached: true },
1497
+ );
1498
+ return this.#buildInMemoryMultiRangeResult(cached.output, parsedUrlTarget.ranges, {
1499
+ details: { ...cached.details },
1500
+ sourceUrl: cached.details.finalUrl,
1501
+ entityLabel: "URL output",
1502
+ raw: parsedUrlTarget.raw,
1503
+ immutable: true,
1504
+ });
1505
+ }
1491
1506
  if (parsedUrlTarget.offset !== undefined || parsedUrlTarget.limit !== undefined) {
1492
1507
  const cached = await loadReadUrlCacheEntry(
1493
1508
  this.session,
@@ -1502,6 +1517,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1502
1517
  details: { ...cached.details },
1503
1518
  sourceUrl: cached.details.finalUrl,
1504
1519
  entityLabel: "URL output",
1520
+ raw: parsedUrlTarget.raw,
1505
1521
  immutable: true,
1506
1522
  });
1507
1523
  }
@@ -1578,7 +1594,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1578
1594
  if (isMultiRange(parsed)) {
1579
1595
  throw new ToolError("Multi-range line selectors are not supported for directory listings.");
1580
1596
  }
1581
- const dirResult = await this.#readDirectory(absolutePath, selToOffsetLimit(parsed).limit, signal);
1597
+ const { offset, limit } = selToOffsetLimit(parsed);
1598
+ const dirResult = await this.#readDirectory(absolutePath, offset, limit, signal);
1582
1599
  if (suffixResolution) {
1583
1600
  dirResult.details ??= {};
1584
1601
  dirResult.details.suffixResolution = suffixResolution;
@@ -2136,6 +2153,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2136
2153
  /** Read directory contents as a formatted listing */
2137
2154
  async #readDirectory(
2138
2155
  absolutePath: string,
2156
+ offset: number | undefined,
2139
2157
  limit: number | undefined,
2140
2158
  signal?: AbortSignal,
2141
2159
  ): Promise<AgentToolResult<ReadToolDetails>> {
@@ -2149,7 +2167,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2149
2167
  maxDepth: READ_DIRECTORY_MAX_DEPTH,
2150
2168
  perDirLimit: READ_DIRECTORY_CHILD_LIMIT,
2151
2169
  rootLimit: null,
2152
- lineCap: limit ?? null,
2170
+ // `lineCap` truncates the rendered tree itself, so apply it only when the caller
2171
+ // did not request an offset — otherwise we'd cap the first N lines before slicing.
2172
+ lineCap: offset === undefined && limit !== undefined ? limit : null,
2153
2173
  });
2154
2174
  } catch (error) {
2155
2175
  const message = error instanceof Error ? error.message : String(error);
@@ -2158,12 +2178,46 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2158
2178
  throwIfAborted(signal);
2159
2179
 
2160
2180
  const output = tree.totalLines <= 1 ? "(empty directory)" : tree.rendered;
2161
- const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
2162
2181
  const details: ReadToolDetails = {
2163
2182
  isDirectory: true,
2164
2183
  resolvedPath: tree.rootPath,
2165
2184
  };
2166
2185
 
2186
+ // Slice the rendered listing when the caller passed an offset/limit. We do this
2187
+ // instead of passing the selector down to `buildDirectoryTree` because the tree
2188
+ // builder lays out entries hierarchically (per-dir caps, recent-then-elided
2189
+ // summaries); line-based slicing operates on the formatted text and matches what
2190
+ // users expect from `:N-M` on long listings.
2191
+ const wantsSlice = offset !== undefined || limit !== undefined;
2192
+ if (wantsSlice) {
2193
+ const allLines = output.split("\n");
2194
+ const start = offset ? Math.max(0, offset - 1) : 0;
2195
+ if (start >= allLines.length) {
2196
+ const suggestion =
2197
+ allLines.length === 0
2198
+ ? "The listing is empty."
2199
+ : `Use :1 to read from the start, or :${allLines.length} to read the last line.`;
2200
+ return toolResult(details)
2201
+ .text(`Line ${start + 1} is beyond end of listing (${allLines.length} lines total). ${suggestion}`)
2202
+ .sourcePath(tree.rootPath)
2203
+ .done();
2204
+ }
2205
+ const end = limit !== undefined ? Math.min(start + limit, allLines.length) : allLines.length;
2206
+ const sliced = allLines.slice(start, end).join("\n");
2207
+ const resultBuilder = toolResult(details).sourcePath(tree.rootPath);
2208
+ let text = sliced;
2209
+ if (end < allLines.length) {
2210
+ const remaining = allLines.length - end;
2211
+ text += `\n\n[${remaining} more lines in listing. Use :${end + 1} to continue]`;
2212
+ }
2213
+ resultBuilder.text(text);
2214
+ if (tree.truncated) {
2215
+ resultBuilder.limits({ resultLimit: 1 });
2216
+ }
2217
+ return resultBuilder.done();
2218
+ }
2219
+
2220
+ const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
2167
2221
  const resultBuilder = toolResult(details).text(truncation.content).sourcePath(tree.rootPath);
2168
2222
  if (tree.truncated) {
2169
2223
  resultBuilder.limits({ resultLimit: 1 });