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.
- package/README.md +74 -57
- package/extensions/guidance.ts +20 -13
- package/extensions/index.ts +72 -6
- package/extensions/io.ts +4 -3
- package/extensions/lsp/servers.ts +63 -3
- package/extensions/mcp/builtin.ts +348 -42
- package/extensions/mcp/client.ts +28 -19
- package/extensions/mcp/index.ts +407 -80
- package/extensions/model-integration.ts +19 -8
- package/extensions/patch.ts +232 -52
- package/extensions/rtk.ts +244 -0
- package/extensions/settings.ts +63 -1
- package/extensions/slash.ts +199 -67
- package/extensions/smart-at.ts +27 -1
- package/extensions/wakatime.ts +403 -0
- package/package.json +4 -5
package/extensions/patch.ts
CHANGED
|
@@ -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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
435
|
+
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
436
|
+
return { error: `${anchorNotFoundMessage}\n${dupDiag}` };
|
|
433
437
|
}
|
|
434
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|