decorated-pi 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -208,8 +208,8 @@ async function applyEdits(
208
208
  throw new ApplyError(`old_str must not be empty in ${displayPath}.`);
209
209
  }
210
210
 
211
- const oldNorm = normalizeLineEndings(edit.old_str);
212
- const newNorm = normalizeLineEndings(edit.new_str);
211
+ let oldNorm = normalizeLineEndings(edit.old_str);
212
+ let newNorm = normalizeLineEndings(edit.new_str);
213
213
 
214
214
  // Determine search range
215
215
  let searchFrom = 0;
@@ -227,46 +227,50 @@ async function applyEdits(
227
227
  } else {
228
228
  const secondAnchor = content.indexOf(anchorNorm, anchorIdx + 1);
229
229
  if (secondAnchor !== -1) {
230
- throw new ApplyError(
231
- `Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}" ` +
232
- `found at multiple locations. Choose a more specific anchor.`
233
- );
230
+ anchorNotFoundMessage = `Anchor is not unique in ${displayPath}: "${truncate(edit.anchor)}".`;
231
+ } else {
232
+ searchFrom = Math.max(0, anchorIdx - (oldNorm.length - 1));
233
+ displayAnchor = edit.anchor;
234
+ anchorMissing = false;
234
235
  }
235
- // Search from anchor start position onward (anchor narrows search range)
236
- // old_str may start at anchor position (anchor is a prefix of old_str)
237
- searchFrom = anchorIdx;
238
- displayAnchor = edit.anchor;
239
- anchorMissing = false;
240
236
  }
241
237
  }
242
238
 
243
239
  // Find old_str in range — must be unique
244
240
  let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
245
- if (matchIdx === -1) {
246
- if (anchorNotFoundMessage) {
247
- displayAnchor = edit.anchor;
248
- anchorMissing = true;
249
- const globalMatchIdx = content.indexOf(oldNorm, 0);
250
- if (globalMatchIdx === -1) {
251
- throw new ApplyError(
252
- `${anchorNotFoundMessage} old_str not found in ${displayPath}: "${truncate(edit.old_str)}". ` +
253
- `The file may have changed — re-read it and try again.`
254
- );
255
- }
256
- const secondGlobalMatch = content.indexOf(oldNorm, globalMatchIdx + 1);
241
+ if (matchIdx === -1 && anchorNotFoundMessage) {
242
+ // Anchor was missing/unusable — try global exact match first
243
+ displayAnchor = edit.anchor;
244
+ anchorMissing = true;
245
+ matchIdx = content.indexOf(oldNorm, 0);
246
+ if (matchIdx !== -1) {
247
+ const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
257
248
  if (secondGlobalMatch !== -1) {
258
- throw new ApplyError(
259
- `${anchorNotFoundMessage} old_str is not unique in ${displayPath}: "${truncate(edit.old_str)}". ` +
260
- `Add more context to old_str or use a more specific anchor.`
261
- );
249
+ const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
250
+ throw new ApplyError(`${anchorNotFoundMessage}\n${dupDiag}`);
262
251
  }
263
- matchIdx = globalMatchIdx;
252
+ }
253
+ }
254
+
255
+ if (matchIdx === -1) {
256
+ // Fuzzy match fallback: normalize tab↔space + trailing whitespace
257
+ const searchLine = searchFrom === 0 ? 0 : content.substring(0, searchFrom).split("\n").length - 1;
258
+ const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
259
+ if (fuzzy) {
260
+ oldNorm = fuzzy.matched;
261
+ matchIdx = fuzzy.idx;
262
+ newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
263
+ } else if (anchorNotFoundMessage) {
264
+ const diag = diagnoseOldStrMismatch(oldNorm, content);
265
+ throw new ApplyError(
266
+ `${anchorNotFoundMessage}\nold_str not found in ${displayPath}: "${truncate(edit.old_str)}".\n${diag}`
267
+ );
264
268
  } else {
269
+ const diag = diagnoseOldStrMismatch(oldNorm, content);
265
270
  throw new ApplyError(
266
271
  `old_str not found in ${displayPath}` +
267
272
  (edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : "") +
268
- `: "${truncate(edit.old_str)}". ` +
269
- `The file may have changed — re-read it and try again.`
273
+ `: "${truncate(edit.old_str)}".\n${diag}`
270
274
  );
271
275
  }
272
276
  }
@@ -275,9 +279,9 @@ async function applyEdits(
275
279
  if (!anchorNotFoundMessage) {
276
280
  const secondMatch = content.indexOf(oldNorm, matchIdx + 1);
277
281
  if (secondMatch !== -1) {
282
+ const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
278
283
  throw new ApplyError(
279
- `old_str is not unique in ${displayPath}: "${truncate(edit.old_str)}". ` +
280
- `Add more context to old_str or use an anchor to narrow the search.`
284
+ `${dupDiag}`
281
285
  );
282
286
  }
283
287
  }
@@ -322,9 +326,10 @@ async function applyEdits(
322
326
 
323
327
  // Generate diff using only needed context lines (no full-file split)
324
328
  const mergedRanges = mergeRanges(neededRanges);
329
+ const currentLineOffsets = buildLineOffsets(content);
325
330
  const neededLines: Map<number, string> = new Map();
326
331
  for (const range of mergedRanges) {
327
- const lines = extractLineRange(content, lineOffsets, range.startLine, range.endLine);
332
+ const lines = extractLineRange(content, currentLineOffsets, range.startLine, range.endLine);
328
333
  for (let i = 0; i < lines.length; i++) {
329
334
  neededLines.set(range.startLine + i, lines[i]);
330
335
  }
@@ -395,8 +400,8 @@ export async function computePatchPreview(
395
400
 
396
401
  for (const edit of patch.edits) {
397
402
  if (!edit.old_str) continue;
398
- const oldNorm = normalizeLineEndings(edit.old_str);
399
- const newNorm = normalizeLineEndings(edit.new_str);
403
+ let oldNorm = normalizeLineEndings(edit.old_str);
404
+ let newNorm = normalizeLineEndings(edit.new_str);
400
405
 
401
406
  let searchFrom = 0;
402
407
  let displayAnchor: string | undefined;
@@ -410,30 +415,42 @@ export async function computePatchPreview(
410
415
  } else {
411
416
  const secondAnchor = content.indexOf(anchorNorm, idx + 1);
412
417
  if (secondAnchor !== -1) {
413
- return { error: `Anchor is not unique: "${truncate(edit.anchor)}"` };
418
+ anchorNotFoundMessage = `Anchor is not unique: "${truncate(edit.anchor)}"`;
419
+ } else {
420
+ searchFrom = Math.max(0, idx - (oldNorm.length - 1));
421
+ displayAnchor = edit.anchor;
422
+ anchorMissing = false;
414
423
  }
415
- searchFrom = idx;
416
- displayAnchor = edit.anchor;
417
- anchorMissing = false;
418
424
  }
419
425
  }
420
426
 
421
427
  let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
422
- if (matchIdx === -1) {
423
- if (anchorNotFoundMessage) {
424
- displayAnchor = edit.anchor;
425
- anchorMissing = true;
426
- const globalMatchIdx = content.indexOf(oldNorm, 0);
427
- if (globalMatchIdx === -1) {
428
- return { error: `${anchorNotFoundMessage}; old_str not found: "${truncate(edit.old_str)}"` };
429
- }
430
- const secondGlobalMatch = content.indexOf(oldNorm, globalMatchIdx + 1);
428
+ if (matchIdx === -1 && anchorNotFoundMessage) {
429
+ displayAnchor = edit.anchor;
430
+ anchorMissing = true;
431
+ matchIdx = content.indexOf(oldNorm, 0);
432
+ if (matchIdx !== -1) {
433
+ const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
431
434
  if (secondGlobalMatch !== -1) {
432
- return { error: `${anchorNotFoundMessage}; old_str is not unique: "${truncate(edit.old_str)}"` };
435
+ const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
436
+ return { error: `${anchorNotFoundMessage}\n${dupDiag}` };
433
437
  }
434
- matchIdx = globalMatchIdx;
438
+ }
439
+ }
440
+
441
+ if (matchIdx === -1) {
442
+ const searchLine = 0;
443
+ const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
444
+ if (fuzzy) {
445
+ oldNorm = fuzzy.matched;
446
+ matchIdx = fuzzy.idx;
447
+ newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
448
+ } else if (anchorNotFoundMessage) {
449
+ const diag = diagnoseOldStrMismatch(oldNorm, content);
450
+ return { error: `${anchorNotFoundMessage}\nold_str not found: "${truncate(edit.old_str)}"\n${diag}` };
435
451
  } else {
436
- return { error: `old_str not found: "${truncate(edit.old_str)}"` };
452
+ const diag = diagnoseOldStrMismatch(oldNorm, content);
453
+ return { error: `old_str not found: "${truncate(edit.old_str)}".\n${diag}` };
437
454
  }
438
455
  }
439
456
 
@@ -456,9 +473,10 @@ export async function computePatchPreview(
456
473
 
457
474
  // Merge needed ranges and extract only those lines
458
475
  const mergedRanges = mergeRanges(neededRanges);
476
+ const currentLineOffsets = buildLineOffsets(content);
459
477
  const neededLines: Map<number, string> = new Map();
460
478
  for (const range of mergedRanges) {
461
- const lines = extractLineRange(content, lineOffsets, range.startLine, range.endLine);
479
+ const lines = extractLineRange(content, currentLineOffsets, range.startLine, range.endLine);
462
480
  for (let i = 0; i < lines.length; i++) {
463
481
  neededLines.set(range.startLine + i, lines[i]);
464
482
  }
@@ -833,6 +851,168 @@ function generateLocalDiff(
833
851
  return parts.join("\n");
834
852
  }
835
853
 
854
+ // ─── old_str mismatch diagnostics ─────────────────────────────────────────
855
+
856
+ /** Detect tab width from the file by analyzing indentation columns of tab-only lines. */
857
+ function detectTabWidth(content: string): number {
858
+ const lines = content.split("\n");
859
+ const cols: number[] = [];
860
+ for (const line of lines) {
861
+ const nonTabIdx = line.search(/[^\t]/);
862
+ if (nonTabIdx === -1 || nonTabIdx === 0) continue;
863
+ cols.push(nonTabIdx);
864
+ }
865
+ if (cols.length < 2) return 0;
866
+ const diffs: number[] = [];
867
+ for (let i = 1; i < cols.length; i++) {
868
+ if (cols[i] === cols[i - 1] || cols[i]! > cols[i - 1]! + 8) continue;
869
+ diffs.push(cols[i]! - cols[i - 1]!);
870
+ }
871
+ if (diffs.length === 0) return 0;
872
+ const sorted = [...diffs].sort((a, b) => a - b);
873
+ const median = sorted[Math.floor(sorted.length / 2)]!;
874
+ return [2, 4, 8].reduce((best, w) => Math.abs(w - median) < Math.abs(best - median) ? w : best, 4);
875
+ }
876
+
877
+ export function diagnoseOldStrNotUnique(oldNorm: string, content: string): string {
878
+ const fileLines = content.split("\n");
879
+ const firstOldLine = (oldNorm.split("\n")[0] ?? "").trim();
880
+ const occurrences: number[] = [];
881
+ let idx = 0;
882
+ while ((idx = content.indexOf(oldNorm, idx)) !== -1) {
883
+ const lineNum = content.substring(0, idx).split("\n").length;
884
+ occurrences.push(lineNum);
885
+ idx++;
886
+ }
887
+ if (occurrences.length === 0) return "";
888
+ const shown = occurrences.slice(0, 5);
889
+ const extra = occurrences.length - shown.length;
890
+ const lines = shown.map((n) => ` line ${n}: "${(fileLines[n - 1] ?? "").replace(/\t/g, "\\t").slice(0, 60)}"`);
891
+ if (extra > 0) lines.push(` and ${extra} more occurrence(s)`);
892
+ return `old_str appears ${occurrences.length} times:\n${lines.join("\n")}\nAdd more surrounding context to make it unique.`;
893
+ }
894
+
895
+ /** Try fuzzy match: normalize tab↔space and trailing whitespace, then search line-by-line. */
896
+ function tryFuzzyLineMatch(
897
+ oldNorm: string,
898
+ content: string,
899
+ searchLineStart: number,
900
+ ): { idx: number; matched: string } | undefined {
901
+ const oldLines = oldNorm.split("\n");
902
+ const fileLines = content.split("\n");
903
+
904
+ const fuzzyEq = (fileLine: string, oldLine: string): boolean => {
905
+ if (fileLine === oldLine) return true;
906
+ for (const tw of [8, 4, 2]) {
907
+ if (fileLine.replace(/\t/g, " ".repeat(tw)) === oldLine.replace(/\t/g, " ".repeat(tw))) return true;
908
+ }
909
+ if (fileLine.replace(/[\t ]+$/, "") === oldLine.replace(/[\t ]+$/, "")) return true;
910
+ return false;
911
+ };
912
+
913
+ for (let i = searchLineStart; i <= fileLines.length - oldLines.length; i++) {
914
+ let ok = true;
915
+ for (let j = 0; j < oldLines.length; j++) {
916
+ if (!fuzzyEq(fileLines[i + j] ?? "", oldLines[j] ?? "")) { ok = false; break; }
917
+ }
918
+ if (ok) {
919
+ let idx = 0;
920
+ for (let k = 0; k < i; k++) idx += (fileLines[k] ?? "").length + 1;
921
+ const matched = oldLines.map((_, j) => fileLines[i + j]).join("\n");
922
+ // Check uniqueness in the fuzzy-matched range
923
+ const secondIdx = content.indexOf(matched, idx + 1);
924
+ if (secondIdx === -1) return { idx, matched };
925
+ }
926
+ }
927
+ return undefined;
928
+ }
929
+
930
+ /** Replace new_str's leading whitespace with the actual file line's leading whitespace style. */
931
+ function normalizeIndentForFuzzy(actualLine: string, newLine: string): string {
932
+ const actualLeading = actualLine.match(/^[\t ]*/)?.[0] ?? "";
933
+ const newLeading = newLine.match(/^[\t ]*/)?.[0] ?? "";
934
+ if (actualLeading === newLeading) return newLine;
935
+ return actualLeading + newLine.slice(newLeading.length);
936
+ }
937
+
938
+ export function diagnoseOldStrMismatch(oldNorm: string, content: string, isConfigFile?: boolean): string {
939
+ const oldLines = oldNorm.split("\n");
940
+ const fileLines = content.split("\n");
941
+ const firstOldLine = oldLines[0] ?? "";
942
+ const parts: string[] = [];
943
+
944
+ // Find the closest matching line in the file
945
+ let bestMatchIdx = -1;
946
+ let bestMatchType = "";
947
+
948
+ for (let i = 0; i < fileLines.length; i++) {
949
+ const fileLine = fileLines[i] ?? "";
950
+
951
+ if (fileLine === firstOldLine) {
952
+ bestMatchIdx = i;
953
+ bestMatchType = "";
954
+ break;
955
+ }
956
+
957
+ if (fileLine.replace(/\t/g, " ") === firstOldLine ||
958
+ fileLine.replace(/\t/g, " ") === firstOldLine ||
959
+ fileLine.replace(/\t/g, " ") === firstOldLine) {
960
+ bestMatchIdx = i;
961
+ bestMatchType = "tab vs space (file has tabs, old_str has spaces)";
962
+ break;
963
+ }
964
+
965
+ if (fileLine.replace(/[\t ]+$/, "") === firstOldLine.replace(/[\t ]+$/, "")) {
966
+ bestMatchIdx = i;
967
+ bestMatchType = "trailing whitespace mismatch";
968
+ break;
969
+ }
970
+
971
+ if (fileLine.toLowerCase() === firstOldLine.toLowerCase()) {
972
+ bestMatchIdx = i;
973
+ bestMatchType = "case mismatch";
974
+ break;
975
+ }
976
+
977
+ const trimmedOld = firstOldLine.trim();
978
+ if (trimmedOld.length > 3 && fileLine.includes(trimmedOld)) {
979
+ if (bestMatchIdx === -1) {
980
+ bestMatchIdx = i;
981
+ bestMatchType = "indent mismatch (content matches, whitespace differs)";
982
+ }
983
+ }
984
+ }
985
+
986
+ if (bestMatchIdx >= 0 && bestMatchType) {
987
+ parts.push(`Hint: ${bestMatchType} at line ${bestMatchIdx + 1}.`);
988
+ parts.push(` actual: ${JSON.stringify(fileLines[bestMatchIdx])}`);
989
+ parts.push(` expected: ${JSON.stringify(firstOldLine)}`);
990
+ } else if (bestMatchIdx >= 0) {
991
+ // First line matched, but full old_str block does not — find the first mismatching line
992
+ const oldArr = oldNorm.split("\n");
993
+ let mismatchLine = 0;
994
+ for (let j = 1; j < oldArr.length; j++) {
995
+ const fileLine = fileLines[bestMatchIdx + j] ?? "<EOF>";
996
+ const oldLine = oldArr[j] ?? "";
997
+ if (fileLine !== oldLine) {
998
+ mismatchLine = bestMatchIdx + j + 1;
999
+ parts.push(`Line ${bestMatchIdx + 1} matches, but diff at line ${mismatchLine}:`);
1000
+ parts.push(` actual: ${JSON.stringify(fileLine)}`);
1001
+ parts.push(` expected: ${JSON.stringify(oldLine)}`);
1002
+ break;
1003
+ }
1004
+ }
1005
+ if (mismatchLine === 0) {
1006
+ parts.push(`First line matches at line ${bestMatchIdx + 1}, but full ${oldArr.length}-line block does not.`);
1007
+ }
1008
+ } else if (firstOldLine.trim().length > 3) {
1009
+ parts.push(`Content "${firstOldLine.trim().slice(0, 60)}" not found anywhere in the file.`);
1010
+ parts.push(`File may have changed — re-read it and try again.`);
1011
+ }
1012
+
1013
+ return parts.join("\n");
1014
+ }
1015
+
836
1016
  function truncate(s: string, maxLen = 60): string {
837
1017
  if (s.length <= maxLen) return s;
838
1018
  // Show first line only
@@ -0,0 +1,244 @@
1
+ /**
2
+ * RTK integration — Rewrite bash commands through system-installed RTK
3
+ *
4
+ * Uses `rtk rewrite` as a preflight step. If RTK is not installed on PATH,
5
+ * this module stays inactive. When a rewritten RTK command fails, the original
6
+ * command is executed once as a fallback.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { createBashToolDefinition, createLocalBashOperations, isToolCallEventType } from "@earendil-works/pi-coding-agent";
11
+ import { Text } from "@earendil-works/pi-tui";
12
+ import { execFileSync, spawnSync } from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+
17
+ let rtkBinary: string | null = null;
18
+
19
+ interface PiShellSettings {
20
+ shellPath?: string;
21
+ shellCommandPrefix?: string;
22
+ }
23
+
24
+ export interface DependencyStatus {
25
+ module: string;
26
+ label: string;
27
+ state: "ok" | "missing" | "n/a";
28
+ detail?: string;
29
+ }
30
+
31
+ function readJsonObject(filePath: string): Record<string, unknown> {
32
+ try {
33
+ if (!fs.existsSync(filePath)) return {};
34
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
35
+ return parsed && typeof parsed === "object" ? parsed : {};
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ function loadPiShellSettings(cwd: string): PiShellSettings {
42
+ const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
43
+ const globalSettings = readJsonObject(path.join(agentDir, "settings.json"));
44
+ const projectSettings = readJsonObject(path.join(cwd, ".pi", "settings.json"));
45
+ const merged = { ...globalSettings, ...projectSettings } as Record<string, unknown>;
46
+ const result: PiShellSettings = {};
47
+ if (typeof merged.shellPath === "string" && merged.shellPath.trim()) result.shellPath = merged.shellPath;
48
+ if (typeof merged.shellCommandPrefix === "string" && merged.shellCommandPrefix.trim()) {
49
+ result.shellCommandPrefix = merged.shellCommandPrefix;
50
+ }
51
+ return result;
52
+ }
53
+
54
+ export function findSystemRtk(): string | null {
55
+ try {
56
+ if (process.platform === "win32") {
57
+ const output = execFileSync("where", ["rtk"], { encoding: "utf-8" }).trim();
58
+ return output.split(/\r?\n/)[0] || null;
59
+ }
60
+ const shell = process.env.SHELL || "sh";
61
+ return execFileSync(shell, ["-lc", "command -v rtk"], { encoding: "utf-8" }).trim() || null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export function shellQuote(value: string): string {
68
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
69
+ }
70
+
71
+ export function buildRtkCommand(raw: string, rtkBinaryPath: string): string {
72
+ const binDir = path.dirname(rtkBinaryPath);
73
+ return `export PATH=${shellQuote(binDir)}:$PATH && ${raw}`;
74
+ }
75
+
76
+ export function extractMainCommand(command: string): string {
77
+ let cmd = command.trim().toLowerCase();
78
+ cmd = cmd.replace(/^cd\s+\S+\s*(&&|;|\n)\s*/, "");
79
+ cmd = cmd.replace(/^(?:[a-z_][a-z0-9_]*=\S*\s+)+/, "");
80
+ const prefixes = ["sudo ", "time ", "nohup ", "nice ", "env "];
81
+ let changed = true;
82
+ while (changed) {
83
+ changed = false;
84
+ for (const prefix of prefixes) {
85
+ if (cmd.startsWith(prefix)) {
86
+ cmd = cmd.slice(prefix.length);
87
+ changed = true;
88
+ }
89
+ }
90
+ }
91
+ return cmd;
92
+ }
93
+
94
+ export function shouldBypassRtkRewrite(command: string): boolean {
95
+ const main = extractMainCommand(command);
96
+ if (!main.startsWith("find ") && main !== "find") return false;
97
+ return /(^|\s)(-o|-or|-a|-and|-not|!|\(|\)|-exec|-ok|-delete|-prune|-printf|-print0)(\s|$)/.test(main);
98
+ }
99
+
100
+ export function rewriteWithRtk(command: string, rtkPath: string): string | null {
101
+ if (shouldBypassRtkRewrite(command)) return null;
102
+
103
+ // NOTE:
104
+ // Some RTK versions return a non-zero exit code even when `rtk rewrite`
105
+ // successfully prints a rewritten command to stdout (observed locally with
106
+ // RTK 0.42.0 returning exit code 3 on success). Because of that, we treat
107
+ // non-empty stdout as the source of truth and ignore the process exit code
108
+ // here. Empty stdout still means “no rewrite available”.
109
+ const result = spawnSync(rtkPath, ["rewrite", command], {
110
+ encoding: "utf-8",
111
+ timeout: 2000,
112
+ });
113
+ const raw = (result.stdout ?? "").trim();
114
+ if (!raw) return null;
115
+ return buildRtkCommand(raw, rtkPath);
116
+ }
117
+
118
+ export function appendStatus(text: string, status: string): string {
119
+ return text ? `${text}\n\n${status}` : status;
120
+ }
121
+
122
+ function formatBashCallWithTag(args: { command?: unknown; timeout?: unknown }, theme: any, showTag: boolean): string {
123
+ const command = typeof args?.command === "string" ? args.command : null;
124
+ const timeout = typeof args?.timeout === "number" ? args.timeout : undefined;
125
+ const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
126
+ const commandDisplay = command === null ? theme.fg("error", "<invalid command>") : command || theme.fg("toolOutput", "...");
127
+ const tag = showTag ? theme.fg("borderAccent", " [RTK]") : "";
128
+ return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix + tag;
129
+ }
130
+
131
+ export async function executeOriginalBash(command: string, cwd: string, timeout: number | undefined, signal?: AbortSignal) {
132
+ const ops = createLocalBashOperations();
133
+ const chunks: Buffer[] = [];
134
+ const onData = (data: Buffer) => chunks.push(Buffer.from(data));
135
+ const getOutput = () => Buffer.concat(chunks).toString("utf-8");
136
+
137
+ try {
138
+ const result = await ops.exec(command, cwd, { onData, signal, timeout });
139
+ const output = getOutput() || "(no output)";
140
+ if (result.exitCode !== 0 && result.exitCode !== null) {
141
+ return {
142
+ content: [{ type: "text" as const, text: appendStatus(output, `Command exited with code ${result.exitCode}`) }],
143
+ details: undefined,
144
+ isError: true,
145
+ };
146
+ }
147
+ return {
148
+ content: [{ type: "text" as const, text: output }],
149
+ details: undefined,
150
+ isError: false,
151
+ };
152
+ } catch (err) {
153
+ const output = getOutput();
154
+ if (err instanceof Error && err.message === "aborted") {
155
+ return {
156
+ content: [{ type: "text" as const, text: appendStatus(output, "Command aborted") }],
157
+ details: undefined,
158
+ isError: true,
159
+ };
160
+ }
161
+ if (err instanceof Error && err.message.startsWith("timeout:")) {
162
+ const timeoutSecs = err.message.split(":")[1];
163
+ return {
164
+ content: [{ type: "text" as const, text: appendStatus(output, `Command timed out after ${timeoutSecs} seconds`) }],
165
+ details: undefined,
166
+ isError: true,
167
+ };
168
+ }
169
+ return {
170
+ content: [{ type: "text" as const, text: appendStatus(output, err instanceof Error ? err.message : "Command failed") }],
171
+ details: undefined,
172
+ isError: true,
173
+ };
174
+ }
175
+ }
176
+
177
+ export function getRtkDependencyStatuses(): DependencyStatus[] {
178
+ return [{
179
+ module: "rtk",
180
+ label: "rtk",
181
+ state: findSystemRtk() ? "ok" : "missing",
182
+ detail: "Install RTK so bash rewrite/tagging can activate.",
183
+ }];
184
+ }
185
+
186
+ export function setupRtkIntegration(pi: ExtensionAPI) {
187
+ rtkBinary = findSystemRtk();
188
+ if (!rtkBinary) return;
189
+
190
+ const rewrittenCommands = new Map<string, { originalCommand: string; timeout?: number }>();
191
+ const rewriteabilityCache = new Map<string, boolean>();
192
+ const shellSettings = loadPiShellSettings(process.cwd());
193
+ const bashTool = createBashToolDefinition(process.cwd(), {
194
+ shellPath: shellSettings.shellPath,
195
+ commandPrefix: shellSettings.shellCommandPrefix,
196
+ });
197
+ const baseRenderCall = bashTool.renderCall?.bind(bashTool);
198
+
199
+ if (baseRenderCall) {
200
+ bashTool.renderCall = (args, theme, context) => {
201
+ const component = baseRenderCall(args, theme, context);
202
+ const command = typeof args?.command === "string" ? args.command : "";
203
+ const predicted = command
204
+ ? (rewriteabilityCache.get(command) ?? (() => {
205
+ const value = rewriteWithRtk(command, rtkBinary!) !== null;
206
+ rewriteabilityCache.set(command, value);
207
+ return value;
208
+ })())
209
+ : false;
210
+ const rewritten = rewrittenCommands.has(context.toolCallId) || predicted;
211
+ if (component instanceof Text) {
212
+ component.setText(formatBashCallWithTag(args as any, theme, rewritten));
213
+ }
214
+ return component;
215
+ };
216
+ }
217
+
218
+ pi.registerTool(bashTool);
219
+
220
+ pi.on("tool_call", (event) => {
221
+ if (!isToolCallEventType("bash", event)) return;
222
+ const command = event.input.command;
223
+ if (!command.trim()) return;
224
+ const rewritten = rewriteWithRtk(command, rtkBinary!);
225
+ rewriteabilityCache.set(command, rewritten !== null);
226
+ if (!rewritten) return;
227
+ rewrittenCommands.set(event.toolCallId, { originalCommand: command, timeout: event.input.timeout });
228
+ event.input.command = rewritten;
229
+ });
230
+
231
+ pi.on("tool_result", async (event, ctx) => {
232
+ if (event.toolName !== "bash") return;
233
+ const pending = rewrittenCommands.get(event.toolCallId);
234
+ if (!pending) return;
235
+ rewrittenCommands.delete(event.toolCallId);
236
+ if (!event.isError) return;
237
+ return executeOriginalBash(pending.originalCommand, ctx.cwd, pending.timeout, ctx.signal);
238
+ });
239
+
240
+ pi.on("session_shutdown", () => {
241
+ rewrittenCommands.clear();
242
+ rewriteabilityCache.clear();
243
+ });
244
+ }