@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.1

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 (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  14. package/src/commit/analysis/conventional.ts +8 -66
  15. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  16. package/src/commit/pipeline.ts +2 -2
  17. package/src/commit/shared-llm.ts +89 -0
  18. package/src/config/config-file.ts +210 -0
  19. package/src/config/model-equivalence.ts +8 -11
  20. package/src/config/model-registry.ts +13 -2
  21. package/src/config/model-resolver.ts +1 -4
  22. package/src/config/settings-schema.ts +71 -1
  23. package/src/config/settings.ts +1 -1
  24. package/src/config.ts +3 -219
  25. package/src/edit/renderer.ts +7 -1
  26. package/src/eval/js/executor.ts +3 -0
  27. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  28. package/src/eval/py/executor.ts +5 -0
  29. package/src/exa/factory.ts +2 -2
  30. package/src/exa/mcp-client.ts +74 -1
  31. package/src/exec/bash-executor.ts +5 -1
  32. package/src/export/html/template.generated.ts +1 -1
  33. package/src/export/html/template.js +0 -11
  34. package/src/extensibility/extensions/runner.ts +1 -1
  35. package/src/extensibility/extensions/types.ts +89 -223
  36. package/src/extensibility/hooks/types.ts +89 -314
  37. package/src/extensibility/shared-events.ts +343 -0
  38. package/src/extensibility/skills.ts +9 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/runtime.ts +500 -0
  41. package/src/goals/state.ts +37 -0
  42. package/src/goals/tools/goal-tool.ts +237 -0
  43. package/src/hashline/anchors.ts +2 -2
  44. package/src/hindsight/mental-models.ts +1 -1
  45. package/src/internal-urls/agent-protocol.ts +1 -20
  46. package/src/internal-urls/artifact-protocol.ts +1 -19
  47. package/src/internal-urls/docs-index.generated.ts +5 -6
  48. package/src/internal-urls/registry-helpers.ts +25 -0
  49. package/src/main.ts +11 -2
  50. package/src/mcp/oauth-flow.ts +20 -0
  51. package/src/modes/acp/acp-agent.ts +79 -45
  52. package/src/modes/components/assistant-message.ts +14 -8
  53. package/src/modes/components/bash-execution.ts +24 -63
  54. package/src/modes/components/custom-message.ts +14 -40
  55. package/src/modes/components/eval-execution.ts +27 -57
  56. package/src/modes/components/execution-shared.ts +102 -0
  57. package/src/modes/components/hook-message.ts +17 -49
  58. package/src/modes/components/mcp-add-wizard.ts +26 -5
  59. package/src/modes/components/message-frame.ts +88 -0
  60. package/src/modes/components/model-selector.ts +1 -1
  61. package/src/modes/components/session-observer-overlay.ts +6 -2
  62. package/src/modes/components/session-selector.ts +1 -1
  63. package/src/modes/components/status-line/segments.ts +55 -4
  64. package/src/modes/components/status-line/types.ts +4 -0
  65. package/src/modes/components/status-line.ts +28 -10
  66. package/src/modes/components/tool-execution.ts +7 -8
  67. package/src/modes/controllers/command-controller-shared.ts +108 -0
  68. package/src/modes/controllers/command-controller.ts +13 -4
  69. package/src/modes/controllers/event-controller.ts +36 -7
  70. package/src/modes/controllers/input-controller.ts +13 -0
  71. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  72. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  73. package/src/modes/interactive-mode.ts +624 -52
  74. package/src/modes/print-mode.ts +16 -86
  75. package/src/modes/rpc/rpc-mode.ts +14 -87
  76. package/src/modes/runtime-init.ts +115 -0
  77. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  78. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  79. package/src/modes/theme/theme.ts +18 -6
  80. package/src/modes/types.ts +14 -3
  81. package/src/modes/utils/context-usage.ts +13 -13
  82. package/src/modes/utils/ui-helpers.ts +10 -3
  83. package/src/plan-mode/approved-plan.ts +35 -1
  84. package/src/prompts/goals/goal-budget-limit.md +16 -0
  85. package/src/prompts/goals/goal-continuation.md +28 -0
  86. package/src/prompts/goals/goal-mode-active.md +23 -0
  87. package/src/prompts/system/plan-mode-active.md +5 -5
  88. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  89. package/src/prompts/tools/bash.md +6 -0
  90. package/src/prompts/tools/goal.md +13 -0
  91. package/src/prompts/tools/hashline.md +102 -114
  92. package/src/prompts/tools/read.md +1 -0
  93. package/src/prompts/tools/resolve.md +6 -5
  94. package/src/sdk.ts +12 -5
  95. package/src/session/agent-session.ts +428 -106
  96. package/src/session/blob-store.ts +36 -3
  97. package/src/session/messages.ts +67 -2
  98. package/src/session/session-manager.ts +131 -12
  99. package/src/session/session-storage.ts +33 -15
  100. package/src/session/streaming-output.ts +309 -13
  101. package/src/slash-commands/builtin-registry.ts +18 -0
  102. package/src/ssh/ssh-executor.ts +5 -0
  103. package/src/system-prompt.ts +4 -2
  104. package/src/task/executor.ts +17 -7
  105. package/src/task/index.ts +3 -0
  106. package/src/task/render.ts +21 -15
  107. package/src/task/types.ts +4 -0
  108. package/src/tools/ast-edit.ts +21 -120
  109. package/src/tools/ast-grep.ts +21 -119
  110. package/src/tools/bash-interactive.ts +9 -1
  111. package/src/tools/bash.ts +27 -4
  112. package/src/tools/browser/attach.ts +3 -3
  113. package/src/tools/browser/launch.ts +81 -18
  114. package/src/tools/browser/registry.ts +1 -5
  115. package/src/tools/browser/tab-supervisor.ts +51 -14
  116. package/src/tools/conflict-detect.ts +15 -4
  117. package/src/tools/eval.ts +3 -1
  118. package/src/tools/find.ts +20 -38
  119. package/src/tools/gh.ts +7 -6
  120. package/src/tools/index.ts +22 -11
  121. package/src/tools/inspect-image.ts +3 -10
  122. package/src/tools/output-meta.ts +176 -37
  123. package/src/tools/path-utils.ts +125 -2
  124. package/src/tools/read.ts +516 -233
  125. package/src/tools/render-utils.ts +92 -0
  126. package/src/tools/renderers.ts +2 -0
  127. package/src/tools/resolve.ts +72 -44
  128. package/src/tools/search.ts +120 -186
  129. package/src/tools/write.ts +44 -9
  130. package/src/utils/file-mentions.ts +1 -1
  131. package/src/utils/image-loading.ts +7 -3
  132. package/src/utils/image-resize.ts +32 -43
  133. package/src/vim/parser.ts +0 -17
  134. package/src/vim/render.ts +1 -1
  135. package/src/vim/types.ts +1 -1
  136. package/src/web/search/providers/gemini.ts +35 -95
  137. package/src/prompts/tools/exit-plan-mode.md +0 -6
  138. package/src/tools/exit-plan-mode.ts +0 -97
  139. package/src/utils/fuzzy.ts +0 -108
  140. package/src/utils/image-convert.ts +0 -27
package/src/tools/read.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  type TruncationResult,
26
26
  truncateHead,
27
27
  truncateHeadBytes,
28
+ truncateLine,
28
29
  } from "../session/streaming-output";
29
30
  import { renderCodeCell, renderMarkdownCell, renderStatusLine } from "../tui";
30
31
  import { CachedOutputBlock } from "../tui/output-block";
@@ -54,7 +55,12 @@ import {
54
55
  renderReadUrlResult,
55
56
  } from "./fetch";
56
57
  import { applyListLimit } from "./list-limit";
57
- import { formatFullOutputReference, formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
58
+ import {
59
+ formatFullOutputReference,
60
+ formatStyledTruncationWarning,
61
+ type OutputMeta,
62
+ resolveOutputMaxColumns,
63
+ } from "./output-meta";
58
64
  import { expandPath, formatPathRelativeToCwd, resolveReadPath, splitPathAndSel } from "./path-utils";
59
65
  import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
60
66
  import {
@@ -81,6 +87,12 @@ const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx"
81
87
 
82
88
  const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
83
89
  const MAX_SUMMARY_LINES = 20_000;
90
+ /**
91
+ * Per-line column cap for file reads. Lines wider than the value of
92
+ * `tools.outputMaxColumns` are ellipsis-truncated at display time; the file
93
+ * on disk is unchanged. Shared with the streaming sink path so one setting
94
+ * covers `bash`/`ssh`/`python`/`js eval` and `read` uniformly.
95
+ */
84
96
  const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]);
85
97
  // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
86
98
  const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
@@ -163,18 +175,25 @@ function countTextLines(text: string): number {
163
175
  const READ_CHUNK_SIZE = 8 * 1024;
164
176
 
165
177
  /**
166
- * Number of unanchored context lines to include before/after a user-requested
167
- * range. Anchor-stale failures are heavily concentrated on edits whose anchors
168
- * land just outside the most recent read window — a few lines of pre-anchored
169
- * context covers off-by-one anchor selection without much cost.
178
+ * Context lines added around an explicit range read. Anchor-stale failures
179
+ * cluster on edits whose anchors land just outside the most recent read
180
+ * window, but the data (`scripts/session-stats/analyze_selector_reads.py`)
181
+ * shows most follow-up reads are disjoint hops, not adjacent extensions —
182
+ * so symmetric padding rarely pays for itself.
183
+ *
184
+ * Leading=1 catches accidental single-line reads where the anchor is the
185
+ * line immediately above the requested start. Trailing=3 buffers the
186
+ * common case where the agent asks for a narrow range and then needs the
187
+ * next few lines to disambiguate an anchor.
170
188
  */
171
- const RANGE_CONTEXT_LINES = 3;
189
+ const RANGE_LEADING_CONTEXT_LINES = 1;
190
+ const RANGE_TRAILING_CONTEXT_LINES = 3;
172
191
 
173
192
  /**
174
- * Expand a [start, end) range with ±RANGE_CONTEXT_LINES context lines on the
193
+ * Expand a [start, end) range with leading/trailing context lines on the
175
194
  * sides where the user actually constrained the range. A start of 0 (no
176
- * explicit offset) does not get leading context — that's already an open-ended
177
- * read from the top.
195
+ * explicit offset) does not get leading context — that's already an
196
+ * open-ended read from the top.
178
197
  */
179
198
  function expandRangeWithContext(
180
199
  requestedStart: number,
@@ -184,8 +203,8 @@ function expandRangeWithContext(
184
203
  expandEnd: boolean,
185
204
  ): { startLine: number; endLine: number } {
186
205
  return {
187
- startLine: expandStart ? Math.max(0, requestedStart - RANGE_CONTEXT_LINES) : requestedStart,
188
- endLine: expandEnd ? Math.min(totalLines, requestedEnd + RANGE_CONTEXT_LINES) : requestedEnd,
206
+ startLine: expandStart ? Math.max(0, requestedStart - RANGE_LEADING_CONTEXT_LINES) : requestedStart,
207
+ endLine: expandEnd ? Math.min(totalLines, requestedEnd + RANGE_TRAILING_CONTEXT_LINES) : requestedEnd,
189
208
  };
190
209
  }
191
210
 
@@ -473,11 +492,13 @@ export interface ReadToolDetails {
473
492
  type ReadParams = ReadToolInput;
474
493
 
475
494
  /** Parsed representation of a path-embedded selector. */
495
+ type LineRange = { startLine: number; endLine: number | undefined };
496
+
476
497
  type ParsedSelector =
477
498
  | { kind: "none" }
478
499
  | { kind: "raw" }
479
500
  | { kind: "conflicts" }
480
- | { kind: "lines"; startLine: number; endLine: number | undefined; raw?: boolean };
501
+ | { kind: "lines"; ranges: [LineRange, ...LineRange[]]; raw?: boolean };
481
502
 
482
503
  const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
483
504
 
@@ -486,7 +507,12 @@ function isRawSelector(parsed: ParsedSelector): boolean {
486
507
  return parsed.kind === "raw" || (parsed.kind === "lines" && parsed.raw === true);
487
508
  }
488
509
 
489
- function parseLineRangeChunk(sel: string): { startLine: number; endLine: number | undefined } | null {
510
+ /** Returns true when the selector requested multiple line ranges. */
511
+ function isMultiRange(parsed: ParsedSelector): boolean {
512
+ return parsed.kind === "lines" && parsed.ranges.length > 1;
513
+ }
514
+
515
+ function parseLineRangeChunk(sel: string): LineRange | null {
490
516
  const lineMatch = LINE_RANGE_RE.exec(sel);
491
517
  if (!lineMatch) return null;
492
518
  const rawStart = Number.parseInt(lineMatch[1]!, 10);
@@ -510,11 +536,45 @@ function parseLineRangeChunk(sel: string): { startLine: number; endLine: number
510
536
  return { startLine: rawStart, endLine: rawEnd };
511
537
  }
512
538
 
539
+ /**
540
+ * Parse a comma-separated list of line ranges (e.g. `5-16,960-973`). Returns
541
+ * the ranges in ascending order with overlapping/adjacent ranges merged so
542
+ * downstream consumers can stream the file in a single forward pass per range.
543
+ */
544
+ function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null {
545
+ const chunks = sel.split(",");
546
+ const parsed: LineRange[] = [];
547
+ for (const chunk of chunks) {
548
+ const range = parseLineRangeChunk(chunk);
549
+ if (!range) return null;
550
+ parsed.push(range);
551
+ }
552
+ if (parsed.length === 0) return null;
553
+ parsed.sort((a, b) => a.startLine - b.startLine);
554
+
555
+ const merged: LineRange[] = [parsed[0]];
556
+ for (let i = 1; i < parsed.length; i++) {
557
+ const current = parsed[i];
558
+ const last = merged[merged.length - 1];
559
+ // Open-ended (endLine undefined) means "to EOF" — any later range is absorbed.
560
+ if (last.endLine === undefined) continue;
561
+ // Merge when current starts within (or immediately after) the last range.
562
+ if (current.startLine <= last.endLine + 1) {
563
+ if (current.endLine === undefined || current.endLine > last.endLine) {
564
+ merged[merged.length - 1] = { startLine: last.startLine, endLine: current.endLine };
565
+ }
566
+ continue;
567
+ }
568
+ merged.push(current);
569
+ }
570
+ return merged as [LineRange, ...LineRange[]];
571
+ }
572
+
513
573
  function parseSel(sel: string | undefined): ParsedSelector {
514
574
  if (!sel || sel.length === 0) return { kind: "none" };
515
575
 
516
576
  // Compound selector: `1-50:raw` or `raw:1-50`. Split into chunks and accept
517
- // any combination of one line range and the literal `raw`.
577
+ // any combination of one line range (possibly multi) and the literal `raw`.
518
578
  if (sel.includes(":")) {
519
579
  const chunks = sel.split(":");
520
580
  if (chunks.length === 2) {
@@ -524,9 +584,9 @@ function parseSel(sel: string | undefined): ParsedSelector {
524
584
  const rangeChunk = aIsRaw ? b : bIsRaw ? a : null;
525
585
  const rawChunk = aIsRaw ? a : bIsRaw ? b : null;
526
586
  if (rangeChunk !== null && rawChunk !== null) {
527
- const range = parseLineRangeChunk(rangeChunk);
528
- if (range) {
529
- return { kind: "lines", startLine: range.startLine, endLine: range.endLine, raw: true };
587
+ const ranges = parseLineRanges(rangeChunk);
588
+ if (ranges) {
589
+ return { kind: "lines", ranges, raw: true };
530
590
  }
531
591
  }
532
592
  }
@@ -536,19 +596,24 @@ function parseSel(sel: string | undefined): ParsedSelector {
536
596
 
537
597
  if (sel.toLowerCase() === "raw") return { kind: "raw" };
538
598
  if (sel.toLowerCase() === "conflicts") return { kind: "conflicts" };
539
- const range = parseLineRangeChunk(sel);
540
- if (range) {
541
- return { kind: "lines", startLine: range.startLine, endLine: range.endLine };
599
+ const ranges = parseLineRanges(sel);
600
+ if (ranges) {
601
+ return { kind: "lines", ranges };
542
602
  }
543
603
  // Unrecognized selectors fall through; sqlite/archive/url readers consume their own colon syntax.
544
604
  return { kind: "none" };
545
605
  }
546
606
 
547
- /** Convert a line-range selector to the offset/limit pair used by internal pagination. */
607
+ /**
608
+ * Convert a single-range selector to the offset/limit pair used by internal pagination.
609
+ * Returns the FIRST range only — multi-range callers MUST branch on `isMultiRange` before
610
+ * calling this helper.
611
+ */
548
612
  function selToOffsetLimit(parsed: ParsedSelector): { offset?: number; limit?: number } {
549
613
  if (parsed.kind === "lines") {
550
- const limit = parsed.endLine !== undefined ? parsed.endLine - parsed.startLine + 1 : undefined;
551
- return { offset: parsed.startLine, limit };
614
+ const first = parsed.ranges[0];
615
+ const limit = first.endLine !== undefined ? first.endLine - first.startLine + 1 : undefined;
616
+ return { offset: first.startLine, limit };
552
617
  }
553
618
  return {};
554
619
  }
@@ -818,6 +883,160 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
818
883
  return resultBuilder.done();
819
884
  }
820
885
 
886
+ /**
887
+ * Render a multi-range read against in-memory text. Each range emits a
888
+ * formatted block with its own anchors / line numbers, blocks are joined
889
+ * with an elision separator, and ranges past EOF surface as `[…]` notices
890
+ * so the model can correct the next call. No leading/trailing context is
891
+ * added — multi-range callers always specify exact bounds.
892
+ */
893
+ #buildInMemoryMultiRangeResult(
894
+ text: string,
895
+ ranges: readonly LineRange[],
896
+ options: {
897
+ details?: ReadToolDetails;
898
+ sourcePath?: string;
899
+ sourceUrl?: string;
900
+ sourceInternal?: string;
901
+ entityLabel: string;
902
+ raw?: boolean;
903
+ immutable?: boolean;
904
+ },
905
+ ): AgentToolResult<ReadToolDetails> {
906
+ const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw, immutable: options.immutable });
907
+ const details = options.details ?? {};
908
+ const allLines = text.split("\n");
909
+ const totalLines = allLines.length;
910
+ const shouldAddHashLines = displayMode.hashLines;
911
+ const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
912
+
913
+ const resultBuilder = toolResult(details);
914
+ if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath);
915
+ if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl);
916
+ if (options.sourceInternal) resultBuilder.sourceInternal(options.sourceInternal);
917
+
918
+ const parts: string[] = [];
919
+ const outOfBounds: LineRange[] = [];
920
+ for (const range of ranges) {
921
+ if (range.startLine > totalLines) {
922
+ outOfBounds.push(range);
923
+ continue;
924
+ }
925
+ const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines);
926
+ const sliced = allLines.slice(range.startLine - 1, effectiveEnd).join("\n");
927
+ parts.push(formatTextWithMode(sliced, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
928
+ }
929
+
930
+ const outputText = parts.length > 0 ? parts.join("\n\n…\n\n") : "";
931
+ const notices: string[] = [];
932
+ for (const range of outOfBounds) {
933
+ const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`;
934
+ notices.push(`[Range ${bound} is beyond end of ${options.entityLabel} (${totalLines} lines total); skipped]`);
935
+ }
936
+ const finalText =
937
+ notices.length > 0 ? (outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n")) : outputText;
938
+ resultBuilder.text(finalText);
939
+ return resultBuilder.done();
940
+ }
941
+
942
+ /**
943
+ * Stream multiple non-contiguous ranges from a local file. ACP bridge takes
944
+ * priority when present (editor buffer is source of truth); otherwise each
945
+ * range is streamed independently with its own line/byte budget. Out-of-bounds
946
+ * ranges surface as inline notices rather than aborting the read.
947
+ */
948
+ async #readLocalFileMultiRange(
949
+ absolutePath: string,
950
+ ranges: readonly LineRange[],
951
+ parsed: ParsedSelector,
952
+ displayMode: { hashLines: boolean; lineNumbers: boolean },
953
+ suffixResolution: { from: string; to: string } | undefined,
954
+ signal: AbortSignal | undefined,
955
+ ): Promise<{
956
+ outputText: string;
957
+ columnTruncated: number;
958
+ bridgeResult?: AgentToolResult<ReadToolDetails>;
959
+ }> {
960
+ const rawSelector = isRawSelector(parsed);
961
+
962
+ // ACP bridge first — the editor's in-memory buffer is source of truth.
963
+ const bridgePromise = this.#routeReadThroughBridge(absolutePath);
964
+ if (bridgePromise !== undefined) {
965
+ try {
966
+ const bridgeText = await bridgePromise;
967
+ const bridgeResult = this.#buildInMemoryMultiRangeResult(bridgeText, ranges, {
968
+ details: { resolvedPath: absolutePath, suffixResolution },
969
+ sourcePath: absolutePath,
970
+ entityLabel: "file",
971
+ raw: rawSelector,
972
+ });
973
+ if (suffixResolution) {
974
+ const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
975
+ const firstText = bridgeResult.content.find((c): c is TextContent => c.type === "text");
976
+ if (firstText) firstText.text = `${notice}\n${firstText.text}`;
977
+ }
978
+ return { outputText: "", columnTruncated: 0, bridgeResult };
979
+ } catch (error) {
980
+ logger.warn("ACP fs readTextFile failed; falling back to disk", { path: absolutePath, error });
981
+ }
982
+ }
983
+
984
+ const shouldAddHashLines = !rawSelector && displayMode.hashLines;
985
+ const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
986
+ const maxColumns = resolveOutputMaxColumns(this.session.settings);
987
+
988
+ const blocks: string[] = [];
989
+ const notices: string[] = [];
990
+ let columnTruncated = 0;
991
+
992
+ for (const range of ranges) {
993
+ const rangeStart = range.startLine - 1; // 0-indexed
994
+ const requestedLength = range.endLine !== undefined ? range.endLine - range.startLine + 1 : this.#defaultLimit;
995
+ const maxLines = Math.min(requestedLength, DEFAULT_MAX_LINES);
996
+ const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLines * 512);
997
+
998
+ const streamResult = await streamLinesFromFile(
999
+ absolutePath,
1000
+ rangeStart,
1001
+ maxLines,
1002
+ maxBytesForRead,
1003
+ maxLines,
1004
+ signal,
1005
+ );
1006
+ const totalFileLines = streamResult.totalFileLines;
1007
+
1008
+ if (rangeStart >= totalFileLines) {
1009
+ const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`;
1010
+ notices.push(`[Range ${bound} is beyond end of file (${totalFileLines} lines total); skipped]`);
1011
+ continue;
1012
+ }
1013
+
1014
+ const collectedLines = streamResult.lines;
1015
+ if (!rawSelector && maxColumns > 0) {
1016
+ for (let i = 0; i < collectedLines.length; i++) {
1017
+ const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
1018
+ if (wasTruncated) {
1019
+ collectedLines[i] = text;
1020
+ columnTruncated = maxColumns;
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ if (collectedLines.length > 0) {
1026
+ getFileReadCache(this.session).recordContiguous(absolutePath, range.startLine, collectedLines);
1027
+ }
1028
+
1029
+ const blockText = collectedLines.join("\n");
1030
+ blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1031
+ }
1032
+
1033
+ let outputText = blocks.join("\n\n…\n\n");
1034
+ if (notices.length > 0) {
1035
+ outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
1036
+ }
1037
+ return { outputText, columnTruncated };
1038
+ }
1039
+
821
1040
  async #readArchiveDirectory(
822
1041
  archive: ArchiveReader,
823
1042
  archivePath: string,
@@ -861,11 +1080,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
861
1080
 
862
1081
  async #readArchive(
863
1082
  readPath: string,
864
- offset: number | undefined,
865
- limit: number | undefined,
1083
+ parsedSel: ParsedSelector,
866
1084
  resolvedArchivePath: ResolvedArchiveReadPath,
867
1085
  signal?: AbortSignal,
868
- options?: { raw?: boolean },
869
1086
  ): Promise<AgentToolResult<ReadToolDetails>> {
870
1087
  throwIfAborted(signal);
871
1088
  const archive = await openArchive(resolvedArchivePath.absolutePath);
@@ -882,6 +1099,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
882
1099
  }
883
1100
 
884
1101
  if (node.isDirectory) {
1102
+ if (isMultiRange(parsedSel)) {
1103
+ throw new ToolError("Multi-range line selectors are not supported for archive directory listings.");
1104
+ }
1105
+ const { limit } = selToOffsetLimit(parsedSel);
885
1106
  return this.#readArchiveDirectory(
886
1107
  archive,
887
1108
  resolvedArchivePath.absolutePath,
@@ -906,12 +1127,26 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
906
1127
  .done();
907
1128
  }
908
1129
 
909
- const result = this.#buildInMemoryTextResult(text, offset, limit, {
910
- details,
911
- sourcePath: resolvedArchivePath.absolutePath,
912
- entityLabel: "archive entry",
913
- raw: options?.raw,
914
- });
1130
+ const raw = isRawSelector(parsedSel);
1131
+ const result =
1132
+ isMultiRange(parsedSel) && parsedSel.kind === "lines"
1133
+ ? this.#buildInMemoryMultiRangeResult(text, parsedSel.ranges, {
1134
+ details,
1135
+ sourcePath: resolvedArchivePath.absolutePath,
1136
+ entityLabel: "archive entry",
1137
+ raw,
1138
+ })
1139
+ : this.#buildInMemoryTextResult(
1140
+ text,
1141
+ selToOffsetLimit(parsedSel).offset,
1142
+ selToOffsetLimit(parsedSel).limit,
1143
+ {
1144
+ details,
1145
+ sourcePath: resolvedArchivePath.absolutePath,
1146
+ entityLabel: "archive entry",
1147
+ raw,
1148
+ },
1149
+ );
915
1150
  const firstText = result.content.find((content): content is TextContent => content.type === "text");
916
1151
  if (firstText) {
917
1152
  firstText.text = prependSuffixResolutionNotice(firstText.text, resolvedArchivePath.suffixResolution);
@@ -1223,22 +1458,18 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1223
1458
  const internalRouter = InternalUrlRouter.instance();
1224
1459
  if (internalRouter.canHandle(internalTarget.path)) {
1225
1460
  const parsed = parseSel(internalTarget.sel);
1226
- const { offset, limit } = selToOffsetLimit(parsed);
1227
- return this.#handleInternalUrl(internalTarget.path, offset, limit, { raw: isRawSelector(parsed) }, signal);
1461
+ return this.#handleInternalUrl(internalTarget.path, parsed, signal);
1228
1462
  }
1229
1463
 
1230
1464
  const archivePath = await this.#resolveArchiveReadPath(readPath, signal);
1231
1465
  if (archivePath) {
1232
1466
  const archiveSubPath = splitPathAndSel(archivePath.archiveSubPath);
1233
1467
  const archiveParsed = parseSel(archiveSubPath.sel);
1234
- const { offset, limit } = selToOffsetLimit(archiveParsed);
1235
1468
  return this.#readArchive(
1236
1469
  readPath,
1237
- offset,
1238
- limit,
1470
+ archiveParsed,
1239
1471
  { ...archivePath, archiveSubPath: archiveSubPath.path },
1240
1472
  signal,
1241
- { raw: isRawSelector(archiveParsed) },
1242
1473
  );
1243
1474
  }
1244
1475
 
@@ -1287,6 +1518,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1287
1518
  }
1288
1519
 
1289
1520
  if (isDirectory) {
1521
+ if (isMultiRange(parsed)) {
1522
+ throw new ToolError("Multi-range line selectors are not supported for directory listings.");
1523
+ }
1290
1524
  const dirResult = await this.#readDirectory(absolutePath, selToOffsetLimit(parsed).limit, signal);
1291
1525
  if (suffixResolution) {
1292
1526
  dirResult.details ??= {};
@@ -1302,13 +1536,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1302
1536
  const imageMetadata = await readImageMetadata(absolutePath);
1303
1537
  const mimeType = imageMetadata?.mimeType;
1304
1538
  const ext = path.extname(absolutePath).toLowerCase();
1305
- const _hasEditTool = this.session.hasEditTool ?? true;
1306
- const _language = getLanguageFromPath(absolutePath);
1307
1539
  const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext);
1308
1540
  // Read the file based on type
1309
1541
  let content: Array<TextContent | ImageContent> | undefined;
1310
1542
  let details: ReadToolDetails = {};
1311
1543
  let sourcePath: string | undefined;
1544
+ let columnTruncated = 0;
1312
1545
  let truncationInfo:
1313
1546
  | { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
1314
1547
  | undefined;
@@ -1372,17 +1605,20 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1372
1605
  }
1373
1606
  }
1374
1607
  } else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) {
1375
- const { offset, limit } = selToOffsetLimit(parsed);
1376
- return this.#buildInMemoryTextResult(
1377
- await readEditableNotebookText(absolutePath, localReadPath),
1378
- offset,
1379
- limit,
1380
- {
1608
+ const notebookText = await readEditableNotebookText(absolutePath, localReadPath);
1609
+ if (isMultiRange(parsed) && parsed.kind === "lines") {
1610
+ return this.#buildInMemoryMultiRangeResult(notebookText, parsed.ranges, {
1381
1611
  details: { resolvedPath: absolutePath },
1382
1612
  sourcePath: absolutePath,
1383
1613
  entityLabel: "notebook",
1384
- },
1385
- );
1614
+ });
1615
+ }
1616
+ const { offset, limit } = selToOffsetLimit(parsed);
1617
+ return this.#buildInMemoryTextResult(notebookText, offset, limit, {
1618
+ details: { resolvedPath: absolutePath },
1619
+ sourcePath: absolutePath,
1620
+ entityLabel: "notebook",
1621
+ });
1386
1622
  } else if (shouldConvertWithMarkit) {
1387
1623
  // Convert document via markit.
1388
1624
  const result = await convertFileWithMarkit(absolutePath, signal);
@@ -1424,197 +1660,230 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1424
1660
  }
1425
1661
 
1426
1662
  if (!content) {
1427
- // Raw text or line-range mode
1428
- const { offset, limit } = selToOffsetLimit(parsed);
1429
- // Try ACP bridge first — editor's in-memory buffer is source of truth.
1430
- // Request full text so local range rendering keeps normal context and line numbers.
1431
- const bridgePromise = this.#routeReadThroughBridge(absolutePath);
1432
- if (bridgePromise !== undefined) {
1433
- try {
1434
- const bridgeText = await bridgePromise;
1435
- const bridgeResult = this.#buildInMemoryTextResult(bridgeText, offset, limit, {
1436
- details: { resolvedPath: absolutePath, suffixResolution },
1437
- sourcePath: absolutePath,
1438
- entityLabel: "file",
1439
- raw: isRawSelector(parsed),
1440
- });
1441
- if (suffixResolution) {
1442
- const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
1443
- const firstText = bridgeResult.content.find((c): c is TextContent => c.type === "text");
1444
- if (firstText) firstText.text = `${notice}\n${firstText.text}`;
1663
+ if (isMultiRange(parsed) && parsed.kind === "lines") {
1664
+ const multiResult = await this.#readLocalFileMultiRange(
1665
+ absolutePath,
1666
+ parsed.ranges,
1667
+ parsed,
1668
+ displayMode,
1669
+ suffixResolution,
1670
+ signal,
1671
+ );
1672
+ if (multiResult.bridgeResult) return multiResult.bridgeResult;
1673
+ content = [{ type: "text", text: multiResult.outputText }];
1674
+ sourcePath = absolutePath;
1675
+ details = {};
1676
+ if (multiResult.columnTruncated > 0) {
1677
+ columnTruncated = multiResult.columnTruncated;
1678
+ }
1679
+ } else {
1680
+ // Raw text or line-range mode
1681
+ const { offset, limit } = selToOffsetLimit(parsed);
1682
+ // Try ACP bridge first — editor's in-memory buffer is source of truth.
1683
+ // Request full text so local range rendering keeps normal context and line numbers.
1684
+ const bridgePromise = this.#routeReadThroughBridge(absolutePath);
1685
+ if (bridgePromise !== undefined) {
1686
+ try {
1687
+ const bridgeText = await bridgePromise;
1688
+ const bridgeResult = this.#buildInMemoryTextResult(bridgeText, offset, limit, {
1689
+ details: { resolvedPath: absolutePath, suffixResolution },
1690
+ sourcePath: absolutePath,
1691
+ entityLabel: "file",
1692
+ raw: isRawSelector(parsed),
1693
+ });
1694
+ if (suffixResolution) {
1695
+ const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
1696
+ const firstText = bridgeResult.content.find((c): c is TextContent => c.type === "text");
1697
+ if (firstText) firstText.text = `${notice}\n${firstText.text}`;
1698
+ }
1699
+ return bridgeResult;
1700
+ } catch (error) {
1701
+ logger.warn("ACP fs readTextFile failed; falling back to disk", { path: absolutePath, error });
1445
1702
  }
1446
- return bridgeResult;
1447
- } catch (error) {
1448
- logger.warn("ACP fs readTextFile failed; falling back to disk", { path: absolutePath, error });
1449
1703
  }
1450
- }
1451
1704
 
1452
- // User-requested 0-indexed range start. Lines BEFORE this become
1453
- // leading context (added below if offset is explicit).
1454
- const requestedStart = offset ? Math.max(0, offset - 1) : 0;
1455
- const expandStart = offset !== undefined && offset > 1;
1456
- const expandEnd = limit !== undefined;
1457
- const leadingContext = expandStart ? Math.min(requestedStart, RANGE_CONTEXT_LINES) : 0;
1458
- const trailingContext = expandEnd ? RANGE_CONTEXT_LINES : 0;
1459
- const startLine = requestedStart - leadingContext;
1460
- const startLineDisplay = startLine + 1;
1461
-
1462
- const DEFAULT_LIMIT = this.#defaultLimit;
1463
- const effectiveLimit = limit ?? DEFAULT_LIMIT;
1464
- const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
1465
- const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
1466
- // Scale byte budget with line limit so the configured line count actually fits.
1467
- // Assume ~512 bytes/line average; never go below the shared default.
1468
- const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
1469
-
1470
- const streamResult = await streamLinesFromFile(
1471
- absolutePath,
1472
- startLine,
1473
- maxLinesToCollect,
1474
- maxBytesForRead,
1475
- selectedLineLimit,
1476
- signal,
1477
- );
1705
+ // User-requested 0-indexed range start. Lines BEFORE this become
1706
+ // leading context (added below if offset is explicit).
1707
+ const requestedStart = offset ? Math.max(0, offset - 1) : 0;
1708
+ const expandStart = offset !== undefined && offset > 1;
1709
+ const expandEnd = limit !== undefined;
1710
+ const leadingContext = expandStart ? Math.min(requestedStart, RANGE_LEADING_CONTEXT_LINES) : 0;
1711
+ const trailingContext = expandEnd ? RANGE_TRAILING_CONTEXT_LINES : 0;
1712
+ const startLine = requestedStart - leadingContext;
1713
+ const startLineDisplay = startLine + 1;
1714
+
1715
+ const DEFAULT_LIMIT = this.#defaultLimit;
1716
+ const effectiveLimit = limit ?? DEFAULT_LIMIT;
1717
+ const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
1718
+ const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
1719
+ // Scale byte budget with line limit so the configured line count actually fits.
1720
+ // Assume ~512 bytes/line average; never go below the shared default.
1721
+ const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
1722
+
1723
+ const streamResult = await streamLinesFromFile(
1724
+ absolutePath,
1725
+ startLine,
1726
+ maxLinesToCollect,
1727
+ maxBytesForRead,
1728
+ selectedLineLimit,
1729
+ signal,
1730
+ );
1478
1731
 
1479
- const {
1480
- lines: collectedLines,
1481
- totalFileLines,
1482
- collectedBytes,
1483
- stoppedByByteLimit,
1484
- firstLinePreview,
1485
- firstLineByteLength,
1486
- } = streamResult;
1487
-
1488
- // Check if offset is out of bounds - return graceful message instead of throwing
1489
- if (requestedStart >= totalFileLines) {
1490
- const suggestion =
1491
- totalFileLines === 0
1492
- ? "The file is empty."
1493
- : `Use :1 to read from the start, or :${totalFileLines} to read the last line.`;
1494
- return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
1495
- .text(
1496
- `Line ${requestedStart + 1} is beyond end of file (${totalFileLines} lines total). ${suggestion}`,
1497
- )
1498
- .done();
1499
- }
1732
+ const {
1733
+ lines: collectedLines,
1734
+ totalFileLines,
1735
+ collectedBytes,
1736
+ stoppedByByteLimit,
1737
+ firstLinePreview,
1738
+ firstLineByteLength,
1739
+ } = streamResult;
1740
+
1741
+ // Check if offset is out of bounds - return graceful message instead of throwing
1742
+ if (requestedStart >= totalFileLines) {
1743
+ const suggestion =
1744
+ totalFileLines === 0
1745
+ ? "The file is empty."
1746
+ : `Use :1 to read from the start, or :${totalFileLines} to read the last line.`;
1747
+ return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
1748
+ .text(
1749
+ `Line ${requestedStart + 1} is beyond end of file (${totalFileLines} lines total). ${suggestion}`,
1750
+ )
1751
+ .done();
1752
+ }
1500
1753
 
1501
- const selectedContent = collectedLines.join("\n");
1502
- const userLimitedLines = collectedLines.length;
1503
-
1504
- const totalSelectedLines = totalFileLines - startLine;
1505
- const totalSelectedBytes = collectedBytes;
1506
- const wasTruncated = collectedLines.length < totalSelectedLines || stoppedByByteLimit;
1507
- const firstLineExceedsLimit = firstLineByteLength !== undefined && firstLineByteLength > maxBytesForRead;
1508
-
1509
- const truncation: TruncationResult = {
1510
- content: selectedContent,
1511
- truncated: wasTruncated,
1512
- truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : undefined,
1513
- totalLines: totalSelectedLines,
1514
- totalBytes: totalSelectedBytes,
1515
- outputLines: collectedLines.length,
1516
- outputBytes: collectedBytes,
1517
- lastLinePartial: false,
1518
- firstLineExceedsLimit,
1519
- };
1754
+ // Per-line column cap. Skipped in raw mode so `:raw` always returns
1755
+ // verbatim bytes for paste-back-into-tool workflows. Total byte/line
1756
+ // counts in `truncation` keep reflecting the source, not the trimmed
1757
+ // view column truncation surfaces separately via `.limits()`.
1758
+ const rawSelector = isRawSelector(parsed);
1759
+ const maxColumns = resolveOutputMaxColumns(this.session.settings);
1760
+ if (!rawSelector && maxColumns > 0) {
1761
+ for (let i = 0; i < collectedLines.length; i++) {
1762
+ const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
1763
+ if (wasTruncated) {
1764
+ collectedLines[i] = text;
1765
+ columnTruncated = maxColumns;
1766
+ }
1767
+ }
1768
+ }
1520
1769
 
1521
- if (collectedLines.length > 0 && !firstLineExceedsLimit) {
1522
- getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines);
1523
- }
1770
+ const selectedContent = collectedLines.join("\n");
1771
+ const userLimitedLines = collectedLines.length;
1772
+
1773
+ const totalSelectedLines = totalFileLines - startLine;
1774
+ const totalSelectedBytes = collectedBytes;
1775
+ const wasTruncated = collectedLines.length < totalSelectedLines || stoppedByByteLimit;
1776
+ const firstLineExceedsLimit = firstLineByteLength !== undefined && firstLineByteLength > maxBytesForRead;
1777
+
1778
+ const truncation: TruncationResult = {
1779
+ content: selectedContent,
1780
+ truncated: wasTruncated,
1781
+ truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : undefined,
1782
+ totalLines: totalSelectedLines,
1783
+ totalBytes: totalSelectedBytes,
1784
+ outputLines: collectedLines.length,
1785
+ outputBytes: collectedBytes,
1786
+ lastLinePartial: false,
1787
+ firstLineExceedsLimit,
1788
+ };
1524
1789
 
1525
- const isRawMode = isRawSelector(parsed);
1526
- const shouldAddHashLines = !isRawMode && displayMode.hashLines;
1527
- const shouldAddLineNumbers = isRawMode ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1528
- let capturedDisplayContent: { text: string; startLine: number } | undefined;
1529
- const formatText = (text: string, startNum: number): string => {
1530
- capturedDisplayContent = { text, startLine: startNum };
1531
- return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
1532
- };
1790
+ if (collectedLines.length > 0 && !firstLineExceedsLimit) {
1791
+ getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines);
1792
+ }
1793
+
1794
+ const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1795
+ const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1796
+ let capturedDisplayContent: { text: string; startLine: number } | undefined;
1797
+ const formatText = (text: string, startNum: number): string => {
1798
+ capturedDisplayContent = { text, startLine: startNum };
1799
+ return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
1800
+ };
1533
1801
 
1534
- let outputText: string;
1802
+ let outputText: string;
1535
1803
 
1536
- if (truncation.firstLineExceedsLimit) {
1537
- const firstLineBytes = firstLineByteLength ?? 0;
1538
- const snippet = firstLinePreview ?? { text: "", bytes: 0 };
1804
+ if (truncation.firstLineExceedsLimit) {
1805
+ const firstLineBytes = firstLineByteLength ?? 0;
1806
+ const snippet = firstLinePreview ?? { text: "", bytes: 0 };
1539
1807
 
1540
- if (shouldAddHashLines) {
1541
- outputText = `[Line ${startLineDisplay} is ${formatBytes(
1542
- firstLineBytes,
1543
- )}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
1808
+ if (shouldAddHashLines) {
1809
+ outputText = `[Line ${startLineDisplay} is ${formatBytes(
1810
+ firstLineBytes,
1811
+ )}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
1812
+ } else {
1813
+ outputText = formatText(snippet.text, startLineDisplay);
1814
+ }
1815
+ if (snippet.text.length === 0) {
1816
+ outputText = `[Line ${startLineDisplay} is ${formatBytes(
1817
+ firstLineBytes,
1818
+ )}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
1819
+ }
1820
+ details = { truncation };
1821
+ sourcePath = absolutePath;
1822
+ truncationInfo = {
1823
+ result: truncation,
1824
+ options: { direction: "head", startLine: startLineDisplay, totalFileLines },
1825
+ };
1826
+ } else if (truncation.truncated) {
1827
+ outputText = formatText(truncation.content, startLineDisplay);
1828
+ details = { truncation };
1829
+ sourcePath = absolutePath;
1830
+ truncationInfo = {
1831
+ result: truncation,
1832
+ options: { direction: "head", startLine: startLineDisplay, totalFileLines },
1833
+ };
1834
+ } else if (startLine + userLimitedLines < totalFileLines) {
1835
+ const remaining = totalFileLines - (startLine + userLimitedLines);
1836
+ const nextOffset = startLine + userLimitedLines + 1;
1837
+
1838
+ outputText = formatText(truncation.content, startLineDisplay);
1839
+ outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`;
1840
+ details = {};
1841
+ sourcePath = absolutePath;
1544
1842
  } else {
1545
- outputText = formatText(snippet.text, startLineDisplay);
1843
+ // No truncation, no user limit exceeded
1844
+ outputText = formatText(truncation.content, startLineDisplay);
1845
+ details = {};
1846
+ sourcePath = absolutePath;
1546
1847
  }
1547
- if (snippet.text.length === 0) {
1548
- outputText = `[Line ${startLineDisplay} is ${formatBytes(
1549
- firstLineBytes,
1550
- )}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
1551
- }
1552
- details = { truncation };
1553
- sourcePath = absolutePath;
1554
- truncationInfo = {
1555
- result: truncation,
1556
- options: { direction: "head", startLine: startLineDisplay, totalFileLines },
1557
- };
1558
- } else if (truncation.truncated) {
1559
- outputText = formatText(truncation.content, startLineDisplay);
1560
- details = { truncation };
1561
- sourcePath = absolutePath;
1562
- truncationInfo = {
1563
- result: truncation,
1564
- options: { direction: "head", startLine: startLineDisplay, totalFileLines },
1565
- };
1566
- } else if (startLine + userLimitedLines < totalFileLines) {
1567
- const remaining = totalFileLines - (startLine + userLimitedLines);
1568
- const nextOffset = startLine + userLimitedLines + 1;
1569
1848
 
1570
- outputText = formatText(truncation.content, startLineDisplay);
1571
- outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`;
1572
- details = {};
1573
- sourcePath = absolutePath;
1574
- } else {
1575
- // No truncation, no user limit exceeded
1576
- outputText = formatText(truncation.content, startLineDisplay);
1577
- details = {};
1578
- sourcePath = absolutePath;
1579
- }
1580
-
1581
- if (capturedDisplayContent) {
1582
- details.displayContent = capturedDisplayContent;
1583
- }
1849
+ if (capturedDisplayContent) {
1850
+ details.displayContent = capturedDisplayContent;
1851
+ }
1584
1852
 
1585
- if (!firstLineExceedsLimit && collectedLines.length > 0) {
1586
- const blocks = scanConflictLines(collectedLines, startLineDisplay);
1587
- if (blocks.length > 0) {
1588
- const history = getConflictHistory(this.session);
1589
- const displayPathForWarning = formatPathRelativeToCwd(absolutePath, this.session.cwd);
1590
- const entries = blocks.map(block =>
1591
- history.register({
1592
- absolutePath,
1853
+ if (!firstLineExceedsLimit && collectedLines.length > 0) {
1854
+ const blocks = scanConflictLines(collectedLines, startLineDisplay);
1855
+ if (blocks.length > 0) {
1856
+ const history = getConflictHistory(this.session);
1857
+ const displayPathForWarning = formatPathRelativeToCwd(absolutePath, this.session.cwd);
1858
+ const entries = blocks.map(block =>
1859
+ history.register({
1860
+ absolutePath,
1861
+ displayPath: displayPathForWarning,
1862
+ ...block,
1863
+ }),
1864
+ );
1865
+ // Cheap full-file scan only when the window already showed
1866
+ // at least one conflict — otherwise pay nothing on clean files.
1867
+ let totalInFile = entries.length;
1868
+ let scanTruncated = false;
1869
+ try {
1870
+ const fileScan = await scanFileForConflicts(absolutePath);
1871
+ totalInFile = Math.max(entries.length, fileScan.blocks.length);
1872
+ scanTruncated = fileScan.scanTruncated;
1873
+ } catch {
1874
+ // Best-effort enrichment; fall back to window-only count.
1875
+ }
1876
+ outputText += formatConflictWarning(entries, {
1877
+ totalInFile,
1593
1878
  displayPath: displayPathForWarning,
1594
- ...block,
1595
- }),
1596
- );
1597
- // Cheap full-file scan only when the window already showed
1598
- // at least one conflict — otherwise pay nothing on clean files.
1599
- let totalInFile = entries.length;
1600
- let scanTruncated = false;
1601
- try {
1602
- const fileScan = await scanFileForConflicts(absolutePath);
1603
- totalInFile = Math.max(entries.length, fileScan.blocks.length);
1604
- scanTruncated = fileScan.scanTruncated;
1605
- } catch {
1606
- // Best-effort enrichment; fall back to window-only count.
1879
+ scanTruncated,
1880
+ });
1881
+ details.conflictCount = entries.length;
1607
1882
  }
1608
- outputText += formatConflictWarning(entries, {
1609
- totalInFile,
1610
- displayPath: displayPathForWarning,
1611
- scanTruncated,
1612
- });
1613
- details.conflictCount = entries.length;
1614
1883
  }
1615
- }
1616
1884
 
1617
- content = [{ type: "text", text: outputText }];
1885
+ content = [{ type: "text", text: outputText }];
1886
+ }
1618
1887
  }
1619
1888
  }
1620
1889
 
@@ -1636,6 +1905,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1636
1905
  if (truncationInfo) {
1637
1906
  resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
1638
1907
  }
1908
+ if (columnTruncated > 0) {
1909
+ resultBuilder.limits({ columnMax: columnTruncated });
1910
+ }
1639
1911
  return resultBuilder.done();
1640
1912
  }
1641
1913
 
@@ -1710,33 +1982,31 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1710
1982
  */
1711
1983
  async #handleInternalUrl(
1712
1984
  url: string,
1713
- offset?: number,
1714
- limit?: number,
1715
- options?: { raw?: boolean },
1985
+ parsedSel: ParsedSelector,
1716
1986
  signal?: AbortSignal,
1717
1987
  ): Promise<AgentToolResult<ReadToolDetails>> {
1718
1988
  const internalRouter = InternalUrlRouter.instance();
1719
1989
 
1720
1990
  // Check if URL has query extraction (agent:// only).
1721
1991
  // Use parseInternalUrl which handles colons in host (namespaced skills).
1722
- let parsed: InternalUrl;
1992
+ let urlMeta: InternalUrl;
1723
1993
  try {
1724
- parsed = parseInternalUrl(url);
1994
+ urlMeta = parseInternalUrl(url);
1725
1995
  } catch (e) {
1726
1996
  throw new ToolError(e instanceof Error ? e.message : String(e));
1727
1997
  }
1728
- const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
1998
+ const scheme = urlMeta.protocol.replace(/:$/, "").toLowerCase();
1729
1999
  let hasExtraction = false;
1730
2000
  if (scheme === "agent") {
1731
- const hasPathExtraction = parsed.pathname && parsed.pathname !== "/" && parsed.pathname !== "";
1732
- const queryParam = parsed.searchParams.get("q");
2001
+ const hasPathExtraction = urlMeta.pathname && urlMeta.pathname !== "/" && urlMeta.pathname !== "";
2002
+ const queryParam = urlMeta.searchParams.get("q");
1733
2003
  const hasQueryExtraction = queryParam !== null && queryParam !== "";
1734
2004
  hasExtraction = hasPathExtraction || hasQueryExtraction;
1735
2005
  }
1736
2006
 
1737
- // Reject offset/limit with query extraction
1738
- if (hasExtraction && (offset !== undefined || limit !== undefined)) {
1739
- throw new ToolError("Cannot combine query extraction with offset/limit");
2007
+ // Reject line selectors when query extraction is used
2008
+ if (hasExtraction && parsedSel.kind !== "none" && parsedSel.kind !== "raw") {
2009
+ throw new ToolError("Cannot combine query extraction with line selectors");
1740
2010
  }
1741
2011
 
1742
2012
  // Resolve the internal URL
@@ -1752,6 +2022,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1752
2022
  return toolResult(details).text(resource.content).sourceInternal(url).done();
1753
2023
  }
1754
2024
 
2025
+ const raw = isRawSelector(parsedSel);
2026
+ if (isMultiRange(parsedSel) && parsedSel.kind === "lines") {
2027
+ return this.#buildInMemoryMultiRangeResult(resource.content, parsedSel.ranges, {
2028
+ details,
2029
+ sourcePath: resource.sourcePath,
2030
+ sourceInternal: url,
2031
+ entityLabel: "resource",
2032
+ immutable: resource.immutable,
2033
+ raw,
2034
+ });
2035
+ }
2036
+
2037
+ const { offset, limit } = selToOffsetLimit(parsedSel);
1755
2038
  return this.#buildInMemoryTextResult(resource.content, offset, limit, {
1756
2039
  details,
1757
2040
  sourcePath: resource.sourcePath,
@@ -1759,7 +2042,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1759
2042
  entityLabel: "resource",
1760
2043
  ignoreResultLimits: scheme === "skill",
1761
2044
  immutable: resource.immutable,
1762
- raw: options?.raw,
2045
+ raw,
1763
2046
  });
1764
2047
  }
1765
2048