pi-readseek 0.3.22 → 0.3.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
4
4
  "description": "Pi extension for readseek-backed hash-anchored read/edit/grep, structural code maps, structural search, and file exploration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,30 +1,41 @@
1
1
  const BASE10_INT_RE = /^-?\d+$/;
2
+ const MIN_SAFE_INTEGER = BigInt(Number.MIN_SAFE_INTEGER);
3
+ const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);
2
4
 
3
5
  type CoercedIntResult =
4
6
  | { ok: true; value: number | undefined }
5
7
  | { ok: false; message: string };
6
8
 
9
+ function formatReceived(value: unknown): string {
10
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
11
+ return JSON.stringify(value) ?? String(value);
12
+ }
13
+
14
+ function invalidInteger(value: unknown, name: string): CoercedIntResult {
15
+ return {
16
+ ok: false,
17
+ message: `Invalid ${name}: expected a safe base-10 integer, received ${formatReceived(value)}.`,
18
+ };
19
+ }
20
+
7
21
  export function coerceObviousBase10Int(value: unknown, name: string): CoercedIntResult {
8
22
  if (value === undefined) {
9
23
  return { ok: true, value: undefined };
10
24
  }
11
25
 
12
26
  if (typeof value === "number") {
13
- if (Number.isInteger(value)) {
27
+ if (Number.isSafeInteger(value)) {
14
28
  return { ok: true, value };
15
29
  }
16
- return {
17
- ok: false,
18
- message: `Invalid ${name}: expected a base-10 integer, received ${value}.`,
19
- };
30
+ return invalidInteger(value, name);
20
31
  }
21
32
 
22
33
  if (typeof value === "string" && BASE10_INT_RE.test(value)) {
23
- return { ok: true, value: Number.parseInt(value, 10) };
34
+ const parsed = BigInt(value);
35
+ if (parsed >= MIN_SAFE_INTEGER && parsed <= MAX_SAFE_INTEGER) {
36
+ return { ok: true, value: Number(parsed) };
37
+ }
24
38
  }
25
39
 
26
- return {
27
- ok: false,
28
- message: `Invalid ${name}: expected a base-10 integer, received ${JSON.stringify(value)}.`,
29
- };
40
+ return invalidInteger(value, name);
30
41
  }
@@ -19,6 +19,19 @@ export interface EditOutputResult {
19
19
  patch: string;
20
20
  readseekValue: ReturnType<typeof buildReadseekEditResult>;
21
21
  }
22
+
23
+ const EDIT_OPERATION_NAMES = ["set_line", "replace_lines", "insert_after", "replace"] as const;
24
+
25
+ type EditOperationName = (typeof EDIT_OPERATION_NAMES)[number];
26
+
27
+ type EditOperationWithNewText = {
28
+ new_text: string;
29
+ };
30
+
31
+ function hasNewText(value: unknown): value is EditOperationWithNewText {
32
+ return typeof value === "object" && value !== null && typeof (value as { new_text?: unknown }).new_text === "string";
33
+ }
34
+
22
35
  function getVisibleDiffStats(diff: string): { added: number; removed: number } {
23
36
  const stats = parseDiffStats(diff);
24
37
  if (stats.added > 0 || stats.removed > 0) return stats;
@@ -49,10 +62,12 @@ function extractNewTextValues(edits: unknown[] | undefined): string[] {
49
62
  const values: string[] = [];
50
63
  for (const edit of edits ?? []) {
51
64
  if (!edit || typeof edit !== "object") continue;
52
- if ("set_line" in edit && typeof (edit as any).set_line?.new_text === "string") values.push((edit as any).set_line.new_text);
53
- if ("replace_lines" in edit && typeof (edit as any).replace_lines?.new_text === "string") values.push((edit as any).replace_lines.new_text);
54
- if ("insert_after" in edit && typeof (edit as any).insert_after?.new_text === "string") values.push((edit as any).insert_after.new_text);
55
- if ("replace" in edit && typeof (edit as any).replace?.new_text === "string") values.push((edit as any).replace.new_text);
65
+
66
+ const operations = edit as Partial<Record<EditOperationName, unknown>>;
67
+ for (const operationName of EDIT_OPERATION_NAMES) {
68
+ const operation = operations[operationName];
69
+ if (hasNewText(operation)) values.push(operation.new_text);
70
+ }
56
71
  }
57
72
  return values;
58
73
  }
@@ -62,10 +77,13 @@ function formatWhitespaceOnlyWarning(semanticSummary: SemanticSummary | undefine
62
77
  return "⚠ Edit classified as whitespace-only — if you intended a behavior change, re-read to verify.";
63
78
  }
64
79
  function formatSemanticSuffix(semanticSummary: SemanticSummary | undefined): string {
65
- const movedBlocks = semanticSummary?.movedBlocks ?? 0;
80
+ if (!semanticSummary) return "";
81
+
82
+ const movedBlocks = semanticSummary.movedBlocks ?? 0;
66
83
  if (movedBlocks <= 0) return "";
84
+
67
85
  const blockWord = movedBlocks === 1 ? "block" : "blocks";
68
- return ` [semantic: ${semanticSummary!.classification}, ${movedBlocks} ${blockWord} moved]`;
86
+ return ` [semantic: ${semanticSummary.classification}, ${movedBlocks} ${blockWord} moved]`;
69
87
  }
70
88
  function formatReplaceHint(edits: unknown[] | undefined, noopEdits: unknown[]): string | undefined {
71
89
  if ((noopEdits ?? []).length > 0) return undefined;
package/src/edit.ts CHANGED
@@ -20,9 +20,10 @@ import { resolveSyntaxValidateMode, type SyntaxValidateOptions } from "./syntax-
20
20
  import { replaceSymbol } from "./replace-symbol.js";
21
21
  import { buildEditPreviewKey, buildPendingEditPreviewData, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
22
22
  import { buildDiffData, type DiffBlockRange } from "./diff-data.js";
23
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
23
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
24
24
  import { DiffPreviewComponent } from "./tui-diff-component.js";
25
25
  import type { FreshAnchorsPredicate } from "./tool-types.js";
26
+ import { registerReadseekTool } from "./register-tool.js";
26
27
 
27
28
  import { resolveEditDiffDisplay } from "./readseek-settings.js";
28
29
 
@@ -566,22 +567,17 @@ export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
566
567
 
567
568
 
568
569
  export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}) {
569
- const toolConfig = {
570
- callable: true,
571
- enabled: true,
572
- policy: "mutating" as const,
573
- readOnly: false,
570
+ const tool = registerReadseekTool(pi, {
571
+ policy: "mutating",
574
572
  pythonName: "edit",
575
- defaultExposure: "not-safe-by-default" as const,
576
- };
577
- const tool = {
573
+ defaultExposure: "not-safe-by-default",
574
+ }, {
578
575
  name: "edit",
579
576
  label: "Edit",
580
577
  description: EDIT_PROMPT_METADATA.description,
581
578
  promptSnippet: EDIT_PROMPT_METADATA.promptSnippet,
582
579
  promptGuidelines: EDIT_PROMPT_METADATA.promptGuidelines,
583
580
  parameters: hashlineEditSchema,
584
- ptc: toolConfig,
585
581
  renderShell: "default" as const,
586
582
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
587
583
  return executeEdit({
@@ -645,7 +641,7 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
645
641
  const { isPartial, isError, expanded: baseExpanded, width, context } = resolveRenderResultContext(options, rest);
646
642
 
647
643
  if (isPartial) {
648
- return new Text(clampLinesToWidth([summaryLine("pending edit")], width).join("\n"), 0, 0);
644
+ return renderPendingResult("pending edit", width);
649
645
  }
650
646
 
651
647
  // Extract data from result
@@ -698,8 +694,6 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
698
694
  }
699
695
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
700
696
  },
701
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
702
-
703
- pi.registerTool(tool);
697
+ });
704
698
  return tool;
705
699
  }
@@ -47,6 +47,14 @@ export interface GrepOutputGroup {
47
47
  };
48
48
  }
49
49
 
50
+ type ScopedGrepOutputGroup = GrepOutputGroup & {
51
+ scope: NonNullable<GrepOutputGroup["scope"]>;
52
+ };
53
+
54
+ function hasScope(group: GrepOutputGroup): group is ScopedGrepOutputGroup {
55
+ return group.scope !== undefined;
56
+ }
57
+
50
58
  export interface BuildGrepOutputInput {
51
59
  summary: boolean;
52
60
  totalMatches: number;
@@ -100,13 +108,13 @@ function buildScopeMetadata(groups: GrepOutputGroup[], warnings: GrepScopeWarnin
100
108
  return {
101
109
  mode: "symbol" as const,
102
110
  groups: groups
103
- .filter((group) => group.scope)
111
+ .filter(hasScope)
104
112
  .map((group) => ({
105
113
  path: group.absolutePath,
106
114
  displayPath: group.displayPath,
107
- symbol: group.scope!.symbol,
115
+ symbol: group.scope.symbol,
108
116
  matchCount: group.matchCount,
109
- matchLines: [...group.scope!.matchLines],
117
+ matchLines: [...group.scope.matchLines],
110
118
  lineAnchors: group.entries.flatMap((entry) => (entry.kind === "separator" ? [] : [entry.line.anchor])),
111
119
  })),
112
120
  warnings: [...warnings],
@@ -132,14 +140,16 @@ export function buildGrepOutput(input: BuildGrepOutputInput): GrepOutputResult {
132
140
  }
133
141
  text = blocks.join("\n");
134
142
  }
135
- if ((input.passthroughLines?.length ?? 0) > 0) {
136
- text += `\n\n${input.passthroughLines!.join("\n")}`;
143
+ const passthroughLines = input.passthroughLines ?? [];
144
+ if (passthroughLines.length > 0) {
145
+ text += `\n\n${passthroughLines.join("\n")}`;
137
146
  }
138
147
  if (input.limit !== undefined && input.totalMatches === input.limit) {
139
148
  text += `\n\n[Results truncated at ${input.limit} matches — refine pattern or increase limit]`;
140
149
  }
141
- if (!input.summary && input.scopeMode === "symbol" && (input.scopeWarnings?.length ?? 0) > 0) {
142
- text = `${input.scopeWarnings!.map((warning) => warning.message).join("\n\n")}\n\n${text}`;
150
+ const scopeWarnings = input.scopeWarnings ?? [];
151
+ if (!input.summary && input.scopeMode === "symbol" && scopeWarnings.length > 0) {
152
+ text = `${scopeWarnings.map((warning) => warning.message).join("\n\n")}\n\n${text}`;
143
153
  }
144
154
  const budget = resolveGrepOutputBudget();
145
155
  const truncated = truncateHead(text, {
@@ -161,7 +171,7 @@ export function buildGrepOutput(input: BuildGrepOutputInput): GrepOutputResult {
161
171
  })),
162
172
  };
163
173
  if (!input.summary && input.scopeMode === "symbol") {
164
- readseekValue.scopes = buildScopeMetadata(input.groups, input.scopeWarnings ?? []);
174
+ readseekValue.scopes = buildScopeMetadata(input.groups, scopeWarnings);
165
175
  }
166
176
 
167
177
  return {
package/src/grep.ts CHANGED
@@ -17,8 +17,9 @@ import { throwIfAborted } from "./runtime.js";
17
17
  import { Text } from "@earendil-works/pi-tui";
18
18
  import { formatGrepCallText, formatGrepResultText } from "./grep-render-helpers.js";
19
19
  import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
20
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
20
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
21
21
  import type { FileAnchoredCallback } from "./tool-types.js";
22
+ import { registerReadseekTool } from "./register-tool.js";
22
23
 
23
24
  const GREP_PROMPT_METADATA = defineToolPromptMetadata({
24
25
  promptUrl: new URL("../prompts/grep.md", import.meta.url),
@@ -313,6 +314,16 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
313
314
  let candidateUnparsedCount = 0;
314
315
  const candidateLinePattern = /^.+(?::|-)\d+(?::|-)\s/;
315
316
 
317
+ const addSummaryMatch = (displayPath: string, absolutePath: string) => {
318
+ let group = groupsByPath.get(displayPath);
319
+ if (!group) {
320
+ group = { displayPath, absolutePath, matchCount: 0, entries: [] };
321
+ groupsByPath.set(displayPath, group);
322
+ }
323
+ group.matchCount++;
324
+ totalMatches++;
325
+ };
326
+
316
327
  const addEntry = (displayPath: string, absolutePath: string, kind: "match" | "context", line: ReadseekLine) => {
317
328
  let group = groupsByPath.get(displayPath);
318
329
  if (!group) {
@@ -341,6 +352,12 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
341
352
  }
342
353
  parsedCount++;
343
354
  const absolute = toAbsolutePath(parsed.displayPath);
355
+ if (p.summary) {
356
+ if (parsed.kind === "match") {
357
+ addSummaryMatch(parsed.displayPath, absolute);
358
+ }
359
+ continue;
360
+ }
344
361
  const fileLines = await getFileLines(absolute);
345
362
  if (fileLines === undefined) continue;
346
363
  // Bare-CR remapping: rg treats the entire bare-CR file as line 1, and the
@@ -379,6 +396,25 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
379
396
  });
380
397
  }
381
398
 
399
+ if (p.summary && parsedCount === 0 && candidateUnparsedCount > 0) {
400
+ const passthroughDetails =
401
+ typeof result.details === "object" && result.details !== null
402
+ ? (result.details as Record<string, unknown>)
403
+ : {};
404
+ return {
405
+ ...result,
406
+ details: {
407
+ ...passthroughDetails,
408
+ readseekValue: {
409
+ tool: "grep",
410
+ summary: true,
411
+ totalMatches: 0,
412
+ records: [],
413
+ },
414
+ },
415
+ };
416
+ }
417
+
382
418
  if (parsedCount === 0 && candidateUnparsedCount > 0) {
383
419
  const warning =
384
420
  "[hashline grep passthrough] Unparsed grep format; returned original output.";
@@ -405,7 +441,7 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
405
441
  };
406
442
  }
407
443
 
408
- const summary = p.summary;
444
+ const summary = !!p.summary;
409
445
  const effectiveLimit = typeof p.limit === "number" ? p.limit : 100;
410
446
  const groups = [...groupsByPath.values()];
411
447
  for (const group of groups) {
@@ -473,21 +509,15 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
473
509
  }
474
510
 
475
511
  export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}) {
476
- const toolConfig = {
477
- callable: true,
478
- enabled: true,
479
- policy: "read-only" as const,
480
- readOnly: true,
512
+ const tool = registerReadseekTool(pi, {
513
+ policy: "read-only",
481
514
  pythonName: "grep",
482
- defaultExposure: "safe-by-default" as const,
483
- };
484
-
485
- const tool = {
515
+ defaultExposure: "safe-by-default",
516
+ }, {
486
517
  name: "grep",
487
518
  label: "grep",
488
519
  description: GREP_PROMPT_METADATA.description,
489
520
  parameters: grepSchema,
490
- ptc: toolConfig,
491
521
  promptSnippet: GREP_PROMPT_METADATA.promptSnippet,
492
522
  promptGuidelines: options.searchGuideline
493
523
  ? [GREP_PROMPT_METADATA.promptGuidelines[0], options.searchGuideline]
@@ -523,16 +553,12 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
523
553
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
524
554
  const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
525
555
 
526
- if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending search")], width).join("\n"), 0, 0);
556
+ if (isPartial) return renderPendingResult("pending search", width);
527
557
 
528
558
  const content = result.content?.[0];
529
559
  const textContent = content?.type === "text" ? content.text : "";
530
560
 
531
- if (isError || result.isError) {
532
- const firstLine = textContent.split("\n")[0] || "Error";
533
- const body = expanded && textContent ? textContent : firstLine;
534
- return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
535
- }
561
+ if (isError || result.isError) return renderErrorResult(textContent, { expanded, width });
536
562
 
537
563
  const readseekValue = (result.details as any)?.readseekValue as {
538
564
  tool: "grep";
@@ -572,8 +598,6 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
572
598
  }
573
599
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
574
600
  },
575
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
576
-
577
- pi.registerTool(tool);
601
+ });
578
602
  return tool;
579
603
  }
package/src/hashline.ts CHANGED
@@ -71,13 +71,16 @@ interface HashlineGlobalState {
71
71
 
72
72
  const HASHLINE_STATE_KEY = Symbol.for("pi-readseek.hashlineState.v1");
73
73
 
74
+ type HashlineGlobalSlots = typeof globalThis & Record<symbol, HashlineGlobalState | undefined>;
75
+
74
76
  function getHashlineState(): HashlineGlobalState {
75
- const globalObject = globalThis as any;
76
- globalObject[HASHLINE_STATE_KEY] ??= {
77
+ const globalObject = globalThis as HashlineGlobalSlots;
78
+ const state = globalObject[HASHLINE_STATE_KEY] ?? {
77
79
  h32Fn: null,
78
80
  initPromise: null,
79
- } satisfies HashlineGlobalState;
80
- return globalObject[HASHLINE_STATE_KEY] as HashlineGlobalState;
81
+ };
82
+ globalObject[HASHLINE_STATE_KEY] = state;
83
+ return state;
81
84
  }
82
85
 
83
86
  export async function ensureHashInit(): Promise<void> {
@@ -123,6 +126,7 @@ export function parseLineRef(ref: string): { line: number; hash: string; content
123
126
  const match = normalized.match(new RegExp(`^(\\d+):([0-9a-fA-F]{${HASH_LEN}})$`));
124
127
  if (!match) throw new Error(`Invalid line reference "${ref}". Expected "LINE:HASH" (e.g. "5:abc").`);
125
128
  const line = Number.parseInt(match[1], 10);
129
+ if (!Number.isSafeInteger(line)) throw new Error(`Line number must be a safe integer, got ${match[1]} in "${ref}".`);
126
130
  if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
127
131
  return { line, hash: match[2], content: contentAfterPipe };
128
132
  }
package/src/map-cache.ts CHANGED
@@ -17,14 +17,17 @@ interface MapCacheGlobalState {
17
17
 
18
18
  const MAP_CACHE_STATE_KEY = Symbol.for("pi-readseek.mapCacheState.v1");
19
19
 
20
+ type MapCacheGlobalSlots = typeof globalThis & Record<symbol, MapCacheGlobalState | undefined>;
21
+
20
22
  function getMapCacheState(): MapCacheGlobalState {
21
- const globalObject = globalThis as any;
22
- globalObject[MAP_CACHE_STATE_KEY] ??= {
23
+ const globalObject = globalThis as MapCacheGlobalSlots;
24
+ const state = globalObject[MAP_CACHE_STATE_KEY] ?? {
23
25
  cache: new Map<string, CacheEntry>(),
24
26
  inflight: new Map<string, Promise<FileMap | null>>(),
25
27
  maxSize: MAP_CACHE_MAX_SIZE,
26
- } satisfies MapCacheGlobalState;
27
- return globalObject[MAP_CACHE_STATE_KEY] as MapCacheGlobalState;
28
+ };
29
+ globalObject[MAP_CACHE_STATE_KEY] = state;
30
+ return state;
28
31
  }
29
32
 
30
33
  function rememberInMemory(absPath: string, entry: CacheEntry): void {
package/src/read.ts CHANGED
@@ -26,8 +26,9 @@ import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
26
26
  import { readseekRead } from "./readseek-client.js";
27
27
  import { Text } from "@earendil-works/pi-tui";
28
28
  import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
29
- import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
29
+ import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
30
30
  import type { FileAnchoredCallback } from "./tool-types.js";
31
+ import { registerReadseekTool } from "./register-tool.js";
31
32
 
32
33
  const READ_PROMPT_METADATA = defineToolPromptMetadata({
33
34
  promptUrl: new URL("../prompts/read.md", import.meta.url),
@@ -40,7 +41,7 @@ interface ReadParams {
40
41
  limit?: number | string;
41
42
  symbol?: string;
42
43
  map?: boolean;
43
- bundle?: "local";
44
+ bundle?: string;
44
45
  }
45
46
 
46
47
  interface ReadToolOptions {
@@ -56,6 +57,15 @@ export interface ExecuteReadOptions {
56
57
  onSuccessfulRead?: FileAnchoredCallback;
57
58
  }
58
59
 
60
+ function hasReadAnchors(result: AgentToolResult<any>): boolean {
61
+ const details = (result as { details?: unknown }).details;
62
+ if (!details || typeof details !== "object") return false;
63
+ const readseekValue = (details as { readseekValue?: unknown }).readseekValue;
64
+ if (!readseekValue || typeof readseekValue !== "object") return false;
65
+ const lines = (readseekValue as { lines?: unknown }).lines;
66
+ return Array.isArray(lines) && lines.length > 0;
67
+ }
68
+
59
69
  export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolResult<any>> {
60
70
  const { toolCallId, params, signal, onUpdate, cwd, onSuccessfulRead } = opts;
61
71
  await ensureHashInit();
@@ -76,10 +86,16 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
76
86
  const message = `Invalid offset: expected a positive integer, received ${offset.value}.`;
77
87
  return buildToolErrorResult("read", "invalid-offset", message, { path: rawParams.path });
78
88
  }
89
+ const rawBundle = typeof rawParams.bundle === "string" ? rawParams.bundle.trim() : undefined;
90
+ const requestedMapViaBundle =
91
+ rawBundle === "map" ||
92
+ (rawBundle === "local" && rawParams.symbol === undefined && rawParams.map !== false);
79
93
  const p = {
80
94
  ...rawParams,
81
95
  offset: offset.value,
82
96
  limit: limit.value,
97
+ map: rawParams.map === true || requestedMapViaBundle,
98
+ bundle: requestedMapViaBundle ? undefined : rawBundle,
83
99
  };
84
100
  if (rawParams.symbol !== undefined) {
85
101
  const trimmedSymbol = typeof rawParams.symbol === "string" ? rawParams.symbol.trim() : "";
@@ -93,13 +109,17 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
93
109
  const absolutePath = resolveToCwd(rawPath, cwd);
94
110
  const succeed = <T extends AgentToolResult<any>>(result: T): T => {
95
111
  const isError = (result as { isError?: boolean }).isError;
96
- if (!isError) {
112
+ if (!isError && hasReadAnchors(result)) {
97
113
  onSuccessfulRead?.(absolutePath);
98
114
  }
99
115
  return result;
100
116
  };
101
117
 
102
118
  throwIfAborted(signal);
119
+ if (p.bundle !== undefined && p.bundle !== "local") {
120
+ const message = `Invalid bundle: expected "local", received ${JSON.stringify(p.bundle)}.`;
121
+ return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
122
+ }
103
123
  if (p.symbol && (p.offset !== undefined || p.limit !== undefined)) {
104
124
  const message = "Cannot combine symbol with offset/limit. Use one or the other.";
105
125
  return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
@@ -404,16 +424,11 @@ function splitReadseekLines(text: string): string[] {
404
424
  }
405
425
 
406
426
  export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}) {
407
- const toolConfig = {
408
- callable: true,
409
- enabled: true,
410
- policy: "read-only" as const,
411
- readOnly: true,
427
+ const tool = registerReadseekTool(pi, {
428
+ policy: "read-only",
412
429
  pythonName: "read",
413
- defaultExposure: "safe-by-default" as const,
414
- };
415
-
416
- const tool = {
430
+ defaultExposure: "safe-by-default",
431
+ }, {
417
432
  name: "read",
418
433
  label: "Read",
419
434
  description: READ_PROMPT_METADATA.description,
@@ -441,7 +456,6 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
441
456
  }),
442
457
  ),
443
458
  }),
444
- ptc: toolConfig,
445
459
  async execute(toolCallId, params, signal, onUpdate, ctx) {
446
460
  return executeRead({
447
461
  toolCallId,
@@ -470,7 +484,7 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
470
484
  },
471
485
  renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
472
486
  const { isPartial, isError, expanded, width } = resolveRenderResultContext(options, rest);
473
- if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending read")], width).join("\n"), 0, 0);
487
+ if (isPartial) return renderPendingResult("pending read", width);
474
488
 
475
489
  const content = result.content?.[0];
476
490
  const textContent = content?.type === "text" ? content.text : "";
@@ -497,8 +511,6 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
497
511
  if (expanded && textContent) text += "\n" + wrapReadHashlinesForWidth(textContent, width);
498
512
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
499
513
  },
500
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
501
-
502
- pi.registerTool(tool);
514
+ });
503
515
  return tool;
504
516
  }
@@ -203,6 +203,28 @@ export function isReadseekAvailable(): boolean {
203
203
  }
204
204
  }
205
205
 
206
+ export interface ReadseekFailure {
207
+ code: "readseek-not-installed" | "readseek-execution-error";
208
+ message: string;
209
+ hint?: string;
210
+ }
211
+
212
+ /**
213
+ * Classify an error thrown while invoking readseek into the shared failure
214
+ * taxonomy: a missing binary or package (`readseek-not-installed`, with an
215
+ * install hint) versus any other execution error.
216
+ */
217
+ export function classifyReadseekFailure(err: unknown): ReadseekFailure {
218
+ const message = String((err as { message?: unknown } | null)?.message || err);
219
+ const missing =
220
+ (err as { code?: unknown } | null)?.code === "ENOENT" ||
221
+ /Cannot find package|Cannot find module|no such file/i.test(message);
222
+ if (missing) {
223
+ return { code: "readseek-not-installed", message, hint: "Run npm install to install @jarkkojs/readseek." };
224
+ }
225
+ return { code: "readseek-execution-error", message };
226
+ }
227
+
206
228
  function directoryExists(dirPath: string): boolean {
207
229
  try {
208
230
  return statSync(dirPath).isDirectory();
@@ -332,7 +354,7 @@ async function runReadseek(args: string[], options: RunReadseekOptions = {}): Pr
332
354
  }
333
355
 
334
356
  function requireNumber(value: unknown, field: string): number {
335
- if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`invalid readseek ${field}`);
357
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new Error(`invalid readseek ${field}: expected safe integer`);
336
358
  return value;
337
359
  }
338
360
 
package/src/refs.ts CHANGED
@@ -6,9 +6,10 @@ import { stat as fsStat } from "node:fs/promises";
6
6
  import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
7
7
  import { buildReadseekLineWithHash, buildToolErrorResult } from "./readseek-value.js";
8
8
  import { resolveToCwd } from "./path-utils.js";
9
- import { isReadseekAvailable, readseekRefs, type ReadseekReference } from "./readseek-client.js";
9
+ import { classifyReadseekFailure, readseekRefs, type ReadseekReference } from "./readseek-client.js";
10
10
  import { buildRefsOutput, type RefsOutputFile, type RefsOutputLine } from "./refs-output.js";
11
11
  import type { FileAnchoredCallback } from "./tool-types.js";
12
+ import { registerReadseekTool } from "./register-tool.js";
12
13
 
13
14
  import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
14
15
 
@@ -29,10 +30,6 @@ const REFS_PROMPT_METADATA = defineToolPromptMetadata({
29
30
  promptSnippet: "Find references to an identifier with readseek and return edit-ready anchors",
30
31
  });
31
32
 
32
- export function isRefsAvailable(): boolean {
33
- return isReadseekAvailable();
34
- }
35
-
36
33
  interface RefsToolOptions {
37
34
  onFileAnchored?: FileAnchoredCallback;
38
35
  }
@@ -127,28 +124,17 @@ export async function executeRefs(opts: ExecuteRefsOptions): Promise<any> {
127
124
  details: { readseekValue: builtOutput.readseekValue },
128
125
  };
129
126
  } catch (err: any) {
130
- const message = String(err?.message || err);
131
- const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
132
- return buildToolErrorResult(
133
- "refs",
134
- missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
135
- message,
136
- missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
137
- );
127
+ const failure = classifyReadseekFailure(err);
128
+ return buildToolErrorResult("refs", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
138
129
  }
139
130
  }
140
131
 
141
132
  export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}) {
142
- const toolConfig = {
143
- callable: true,
144
- enabled: true,
145
- policy: "read-only" as const,
146
- readOnly: true,
133
+ const tool = registerReadseekTool(pi, {
134
+ policy: "read-only",
147
135
  pythonName: "refs",
148
- defaultExposure: "opt-in" as const,
149
- };
150
-
151
- const tool = {
136
+ defaultExposure: "opt-in",
137
+ }, {
152
138
  name: "refs",
153
139
  label: "References",
154
140
  description: REFS_PROMPT_METADATA.description,
@@ -165,7 +151,6 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
165
151
  others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
166
152
  ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
167
153
  }),
168
- ptc: toolConfig,
169
154
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
170
155
  return executeRefs({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
171
156
  },
@@ -186,8 +171,6 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
186
171
  unitPlural: "references",
187
172
  });
188
173
  },
189
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
190
-
191
- pi.registerTool(tool);
174
+ });
192
175
  return tool;
193
176
  }
@@ -0,0 +1,47 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type ReadseekToolPolicy = "read-only" | "mutating";
4
+
5
+ export type ReadseekToolExposure = "safe-by-default" | "opt-in" | "not-safe-by-default";
6
+
7
+ /**
8
+ * Per-tool fields of the pi tool config (`ptc`) that genuinely differ between
9
+ * readseek tools. The remaining fields are constant or derived.
10
+ */
11
+ export interface ReadseekToolConfig {
12
+ policy: ReadseekToolPolicy;
13
+ pythonName: string;
14
+ defaultExposure: ReadseekToolExposure;
15
+ }
16
+
17
+ export interface ReadseekToolPtc extends ReadseekToolConfig {
18
+ callable: true;
19
+ enabled: true;
20
+ readOnly: boolean;
21
+ }
22
+
23
+ type ToolSpec = Parameters<ExtensionAPI["registerTool"]>[0];
24
+
25
+ /**
26
+ * Attach the standard `ptc` envelope to a tool definition and register it.
27
+ *
28
+ * `callable` and `enabled` are always true and `readOnly` is derived from
29
+ * `policy`, so callers supply only the fields that vary between tools.
30
+ */
31
+ export function registerReadseekTool<T extends ToolSpec>(
32
+ pi: ExtensionAPI,
33
+ config: ReadseekToolConfig,
34
+ tool: T,
35
+ ): T & { ptc: ReadseekToolPtc } {
36
+ const ptc: ReadseekToolPtc = {
37
+ callable: true,
38
+ enabled: true,
39
+ policy: config.policy,
40
+ readOnly: config.policy === "read-only",
41
+ pythonName: config.pythonName,
42
+ defaultExposure: config.defaultExposure,
43
+ };
44
+ const registered = { ...tool, ptc };
45
+ pi.registerTool(registered);
46
+ return registered;
47
+ }
package/src/sg.ts CHANGED
@@ -6,9 +6,10 @@ import { stat as fsStat } from "node:fs/promises";
6
6
  import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
7
7
  import { buildReadseekLineWithHash, buildToolErrorResult, type ReadseekLine } from "./readseek-value.js";
8
8
  import { resolveToCwd } from "./path-utils.js";
9
- import { isReadseekAvailable, readseekSearch, type ReadseekHashline, type ReadseekSearchFileOutput } from "./readseek-client.js";
9
+ import { classifyReadseekFailure, isReadseekAvailable, readseekSearch, type ReadseekHashline, type ReadseekSearchFileOutput } from "./readseek-client.js";
10
10
  import { buildSgOutput } from "./sg-output.js";
11
11
  import type { FileAnchoredCallback } from "./tool-types.js";
12
+ import { registerReadseekTool } from "./register-tool.js";
12
13
 
13
14
  import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
14
15
 
@@ -193,28 +194,17 @@ export async function executeSg(opts: ExecuteSgOptions): Promise<any> {
193
194
  },
194
195
  };
195
196
  } catch (err: any) {
196
- const message = String(err?.message || err);
197
- const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
198
- return buildToolErrorResult(
199
- "search",
200
- missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
201
- message,
202
- missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
203
- );
197
+ const failure = classifyReadseekFailure(err);
198
+ return buildToolErrorResult("search", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
204
199
  }
205
200
  }
206
201
 
207
202
  export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
208
- const toolConfig = {
209
- callable: true,
210
- enabled: true,
211
- policy: "read-only" as const,
212
- readOnly: true,
203
+ const tool = registerReadseekTool(pi, {
204
+ policy: "read-only",
213
205
  pythonName: "search",
214
- defaultExposure: "opt-in" as const,
215
- };
216
-
217
- const tool = {
206
+ defaultExposure: "opt-in",
207
+ }, {
218
208
  name: "search",
219
209
  label: "Structural Search",
220
210
  description: SG_PROMPT_METADATA.description,
@@ -228,7 +218,6 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
228
218
  others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
229
219
  ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
230
220
  }),
231
- ptc: toolConfig,
232
221
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
233
222
  return executeSg({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
234
223
  },
@@ -249,8 +238,6 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
249
238
  unitPlural: "matches",
250
239
  });
251
240
  },
252
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
253
-
254
- pi.registerTool(tool);
241
+ });
255
242
  return tool;
256
243
  }
@@ -156,6 +156,27 @@ export function wrapReadHashlinesForWidth(text: string, width: number | undefine
156
156
  return output.join("\n");
157
157
  }
158
158
 
159
+ /**
160
+ * Render the `isPartial` placeholder line shared by every tool's `renderResult`.
161
+ */
162
+ export function renderPendingResult(pendingLabel: string, width: number | undefined): Text {
163
+ return new Text(clampLinesToWidth([summaryLine(pendingLabel)], width).join("\n"), 0, 0);
164
+ }
165
+
166
+ /**
167
+ * Render a tool result error: the first line of `textContent`, or its full body
168
+ * when expanded, prefixed and clamped to width. Tools pass their own `fallback`
169
+ * for the empty-content case.
170
+ */
171
+ export function renderErrorResult(
172
+ textContent: string,
173
+ options: { expanded: boolean; width: number | undefined; fallback?: string },
174
+ ): Text {
175
+ const firstLine = textContent.split("\n")[0] || (options.fallback ?? "Error");
176
+ const body = options.expanded && textContent ? textContent : firstLine;
177
+ return new Text(clampLinesToWidth(summaryLine(body).split("\n"), options.width).join("\n"), 0, 0);
178
+ }
179
+
159
180
  export interface AnchoredFilesLabels {
160
181
  pendingLabel: string;
161
182
  emptyLabel: string;
@@ -178,15 +199,11 @@ export function renderAnchoredFilesResult(
178
199
  ): Text {
179
200
  const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
180
201
 
181
- if (isPartial) return new Text(clampLinesToWidth([summaryLine(labels.pendingLabel)], width).join("\n"), 0, 0);
202
+ if (isPartial) return renderPendingResult(labels.pendingLabel, width);
182
203
 
183
204
  const content = result.content?.[0];
184
205
  const textContent = content?.type === "text" ? content.text : "";
185
- if (isError || result.isError) {
186
- const firstLine = textContent.split("\n")[0] || "Error";
187
- const body = expanded && textContent ? textContent : firstLine;
188
- return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
189
- }
206
+ if (isError || result.isError) return renderErrorResult(textContent, { expanded, width });
190
207
 
191
208
  const readseekValue = (result.details as any)?.readseekValue as
192
209
  | { files: Array<{ path: string; lines: any[] }> }
package/src/write.ts CHANGED
@@ -14,9 +14,10 @@ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
14
14
  import { buildPendingWritePreviewData, buildWritePreviewKey, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
15
15
  import { generateCompactOrFullDiff, normalizeToLF, hasBareCarriageReturn } from "./edit-diff.js";
16
16
  import { buildDiffData, type DiffData } from "./diff-data.js";
17
- import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderToolLabel, summaryLine } from "./tui-render-utils.js";
17
+ import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderErrorResult, renderToolLabel, summaryLine } from "./tui-render-utils.js";
18
18
  import { DiffPreviewComponent } from "./tui-diff-component.js";
19
19
  import type { FileAnchoredCallback } from "./tool-types.js";
20
+ import { registerReadseekTool } from "./register-tool.js";
20
21
 
21
22
  const WRITE_PENDING_PREVIEW_STATE_KEY = "hashline-write-pending-preview";
22
23
 
@@ -169,6 +170,30 @@ function mapFsWriteError(err: any, path: string): MappedFsError {
169
170
  };
170
171
  }
171
172
 
173
+ function buildWriteFsErrorResult(err: any, absolutePath: string) {
174
+ const mapped = mapFsWriteError(err, absolutePath);
175
+ return {
176
+ content: [{ type: "text" as const, text: mapped.message }],
177
+ isError: true,
178
+ details: {
179
+ readseekValue: {
180
+ tool: "write" as const,
181
+ path: absolutePath,
182
+ lines: [] as ReadseekLine[],
183
+ warnings: [] as ReadseekWarning[],
184
+ ok: false,
185
+ error: buildReadseekError(
186
+ mapped.code,
187
+ mapped.message,
188
+ undefined,
189
+ mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
190
+ ),
191
+ },
192
+ warnings: [] as string[],
193
+ },
194
+ };
195
+ }
196
+
172
197
  export async function executeWrite(opts: {
173
198
  path: string;
174
199
  content: string;
@@ -301,21 +326,16 @@ export async function executeWrite(opts: {
301
326
  }
302
327
 
303
328
  export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions = {}) {
304
- const toolConfig = {
305
- callable: true,
306
- enabled: true,
307
- policy: "mutating" as const,
308
- readOnly: false,
329
+ const tool = registerReadseekTool(pi, {
330
+ policy: "mutating",
309
331
  pythonName: "write",
310
- defaultExposure: "not-safe-by-default" as const,
311
- };
312
- const tool = {
332
+ defaultExposure: "not-safe-by-default",
333
+ }, {
313
334
  name: "write",
314
335
  label: "write",
315
336
  description: WRITE_PROMPT_METADATA.description,
316
337
  promptSnippet: WRITE_PROMPT_METADATA.promptSnippet,
317
338
  promptGuidelines: WRITE_PROMPT_METADATA.promptGuidelines,
318
- ptc: toolConfig,
319
339
  parameters: Type.Object({
320
340
  path: Type.String({ description: "File path" }),
321
341
  content: Type.String({ description: "File content" }),
@@ -325,112 +345,72 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
325
345
  const cwd = ctx?.cwd ?? process.cwd();
326
346
  const absolutePath = resolveToCwd(params.path, cwd);
327
347
  try {
328
- return await withFileMutationQueue(absolutePath, async () => {
329
- let result: WriteResult;
330
- try {
331
- result = await executeWrite({
332
- path: absolutePath,
333
- content: params.content,
334
- map: params.map,
335
- cwd,
336
- });
337
- } catch (err: any) {
338
- const mapped = mapFsWriteError(err, absolutePath);
339
- return {
340
- content: [{ type: "text" as const, text: mapped.message }],
341
- isError: true,
342
- details: {
343
- readseekValue: {
344
- tool: "write" as const,
348
+ return await withFileMutationQueue(absolutePath, async () => {
349
+ let result: WriteResult;
350
+ try {
351
+ result = await executeWrite({
345
352
  path: absolutePath,
346
- lines: [] as ReadseekLine[],
347
- warnings: [] as ReadseekWarning[],
348
- ok: false,
349
- error: buildReadseekError(
350
- mapped.code,
351
- mapped.message,
352
- undefined,
353
- mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
354
- ),
355
- },
356
- warnings: [] as string[],
357
- },
358
- };
359
- }
353
+ content: params.content,
354
+ map: params.map,
355
+ cwd,
356
+ });
357
+ } catch (err: any) {
358
+ return buildWriteFsErrorResult(err, absolutePath);
359
+ }
360
360
 
361
- if (result.readseekValue.lines.length > 0) {
362
- options.onFileAnchored?.(absolutePath);
363
- }
361
+ if (result.readseekValue.lines.length > 0) {
362
+ options.onFileAnchored?.(absolutePath);
363
+ }
364
364
 
365
- // Lift binary-content signal into a fatal readseekValue.error envelope so
366
- // downstream consumers get the same taxonomy shape as every other tool.
367
- // The existing ReadseekWarning entry is preserved on readseekValue.warnings for
368
- // backward compatibility (see AC 12 — warnings namespace alignment).
369
- const binaryWarning = result.readseekValue.warnings.find((w) => w.code === "binary-content");
370
- if (binaryWarning) {
371
- return {
372
- content: [{ type: "text" as const, text: result.text }],
373
- isError: true,
374
- details: {
375
- readseekValue: {
376
- ...result.readseekValue,
377
- ok: false,
378
- error: buildReadseekError("binary-content", binaryWarning.message),
379
- },
380
- warnings: result.warnings,
381
- },
382
- };
383
- }
365
+ // Lift binary-content signal into a fatal readseekValue.error envelope so
366
+ // downstream consumers get the same taxonomy shape as every other tool.
367
+ // The existing ReadseekWarning entry is preserved on readseekValue.warnings for
368
+ // backward compatibility (see AC 12 — warnings namespace alignment).
369
+ const binaryWarning = result.readseekValue.warnings.find((w) => w.code === "binary-content");
370
+ if (binaryWarning) {
371
+ return {
372
+ content: [{ type: "text" as const, text: result.text }],
373
+ isError: true,
374
+ details: {
375
+ readseekValue: {
376
+ ...result.readseekValue,
377
+ ok: false,
378
+ error: buildReadseekError("binary-content", binaryWarning.message),
379
+ },
380
+ warnings: result.warnings,
381
+ },
382
+ };
383
+ }
384
384
 
385
- const bareCrWarning = result.readseekValue.warnings.find((w) => w.code === "bare-cr");
386
- if (bareCrWarning) {
387
- return {
388
- content: [{ type: "text" as const, text: result.text }],
389
- isError: true,
390
- details: {
391
- readseekValue: {
392
- ...result.readseekValue,
393
- ok: false,
394
- error: buildReadseekError("bare-cr", bareCrWarning.message),
395
- },
396
- warnings: result.warnings,
397
- },
398
- };
399
- }
385
+ const bareCrWarning = result.readseekValue.warnings.find((w) => w.code === "bare-cr");
386
+ if (bareCrWarning) {
387
+ return {
388
+ content: [{ type: "text" as const, text: result.text }],
389
+ isError: true,
390
+ details: {
391
+ readseekValue: {
392
+ ...result.readseekValue,
393
+ ok: false,
394
+ error: buildReadseekError("bare-cr", bareCrWarning.message),
395
+ },
396
+ warnings: result.warnings,
397
+ },
398
+ };
399
+ }
400
400
 
401
- return {
402
- content: [{ type: "text" as const, text: result.text }],
403
- details: {
404
- ...(result.diff !== undefined ? { diff: result.diff } : {}),
405
- ...(result.diffData !== undefined ? { diffData: result.diffData } : {}),
406
- ...(result.writeState ? { writeState: result.writeState } : {}),
407
- readseekValue: result.readseekValue,
408
- warnings: result.warnings,
409
- },
410
- };
411
- });
412
- } catch (err: any) {
413
- const mapped = mapFsWriteError(err, absolutePath);
414
- return {
415
- content: [{ type: "text" as const, text: mapped.message }],
416
- isError: true,
417
- details: {
418
- readseekValue: {
419
- tool: "write" as const,
420
- path: absolutePath,
421
- lines: [] as ReadseekLine[],
422
- warnings: [] as ReadseekWarning[],
423
- ok: false,
424
- error: buildReadseekError(
425
- mapped.code,
426
- mapped.message,
427
- undefined,
428
- mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
429
- ),
401
+ return {
402
+ content: [{ type: "text" as const, text: result.text }],
403
+ details: {
404
+ ...(result.diff !== undefined ? { diff: result.diff } : {}),
405
+ ...(result.diffData !== undefined ? { diffData: result.diffData } : {}),
406
+ ...(result.writeState ? { writeState: result.writeState } : {}),
407
+ readseekValue: result.readseekValue,
408
+ warnings: result.warnings,
430
409
  },
431
- warnings: [] as string[],
432
- },
433
- };
410
+ };
411
+ });
412
+ } catch (err: any) {
413
+ return buildWriteFsErrorResult(err, absolutePath);
434
414
  }
435
415
  },
436
416
  renderCall(args: any, theme: any, context: any = {}) {
@@ -478,9 +458,7 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
478
458
  const details = result.details ?? {};
479
459
  const output = result.content?.[0]?.type === "text" ? result.content[0].text : "";
480
460
  if (result.isError || details.readseekValue?.ok === false) {
481
- const firstLine = output.split("\n")[0] || "write failed";
482
- const body = expanded && output ? output : firstLine;
483
- return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
461
+ return renderErrorResult(output, { expanded, width, fallback: "write failed" });
484
462
  }
485
463
  const diffData = details.diffData;
486
464
  const state = details.writeState === "overwritten" ? "overwritten" : "created";
@@ -510,7 +488,6 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
510
488
  }
511
489
  return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
512
490
  },
513
- } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
514
- pi.registerTool(tool);
491
+ });
515
492
  return tool;
516
493
  }