@oh-my-pi/pi-coding-agent 14.4.4 → 14.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/package.json +9 -8
- package/src/edit/modes/atom.ts +15 -30
- package/src/edit/modes/hashline.ts +0 -50
- package/src/edit/renderer.ts +16 -31
- package/src/modes/components/diff.ts +5 -4
- package/src/modes/controllers/event-controller.ts +8 -2
- package/src/tools/browser.ts +157 -35
- package/src/web/search/providers/zai.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [14.5.1] - 2026-04-26
|
|
6
|
+
|
|
7
|
+
### Removed
|
|
8
|
+
|
|
9
|
+
- Removed `\t` escaped-tab indentation autocorrect from hashline and atom edit modes (and the `PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS` environment toggle); literal `\t` in edit content is now preserved verbatim
|
|
10
|
+
- Removed the suspicious-`\uDDDD` warning preflight from hashline edits
|
|
11
|
+
- Removed the hand-rolled JSON unescape fallback in the streaming edit-arg renderer; partial fragments that fail `JSON.parse` are now surfaced raw rather than partially decoded with a non-spec-compliant unescaper that mishandled lone surrogates
|
|
12
|
+
|
|
5
13
|
## [14.4.3] - 2026-04-26
|
|
6
14
|
### Added
|
|
7
15
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "14.
|
|
4
|
+
"version": "14.5.1",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,12 +46,13 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@agentclientprotocol/sdk": "0.20.0",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"@oh-my-pi/omp-stats": "14.
|
|
50
|
-
"@oh-my-pi/pi-agent-core": "14.
|
|
51
|
-
"@oh-my-pi/pi-ai": "14.
|
|
52
|
-
"@oh-my-pi/pi-natives": "14.
|
|
53
|
-
"@oh-my-pi/pi-tui": "14.
|
|
54
|
-
"@oh-my-pi/pi-utils": "14.
|
|
49
|
+
"@oh-my-pi/omp-stats": "14.5.1",
|
|
50
|
+
"@oh-my-pi/pi-agent-core": "14.5.1",
|
|
51
|
+
"@oh-my-pi/pi-ai": "14.5.1",
|
|
52
|
+
"@oh-my-pi/pi-natives": "14.5.1",
|
|
53
|
+
"@oh-my-pi/pi-tui": "14.5.1",
|
|
54
|
+
"@oh-my-pi/pi-utils": "14.5.1",
|
|
55
|
+
"@puppeteer/browsers": "^2.13.0",
|
|
55
56
|
"@sinclair/typebox": "^0.34.49",
|
|
56
57
|
"@xterm/headless": "^6.0.0",
|
|
57
58
|
"ajv": "^8.20.0",
|
|
@@ -62,7 +63,7 @@
|
|
|
62
63
|
"linkedom": "^0.18.12",
|
|
63
64
|
"lru-cache": "11.3.5",
|
|
64
65
|
"markit-ai": "0.5.3",
|
|
65
|
-
"puppeteer": "^24.42.0",
|
|
66
|
+
"puppeteer-core": "^24.42.0",
|
|
66
67
|
"turndown": "7.2.4",
|
|
67
68
|
"turndown-plugin-gfm": "1.0.2",
|
|
68
69
|
"zod": "4.3.6"
|
package/src/edit/modes/atom.ts
CHANGED
|
@@ -355,6 +355,19 @@ function applySedToLine(
|
|
|
355
355
|
}
|
|
356
356
|
if (re?.test(currentLine)) {
|
|
357
357
|
re.lastIndex = 0;
|
|
358
|
+
const probe = re.exec(currentLine);
|
|
359
|
+
re.lastIndex = 0;
|
|
360
|
+
if (probe && probe[0].length === 0) {
|
|
361
|
+
// Zero-length matches (e.g. `()`, `(?=…)`, `^`, `$`) cause `String.replace`
|
|
362
|
+
// to insert the replacement at the match position rather than substitute,
|
|
363
|
+
// which is almost never what models intend. Reject with a pointer to the
|
|
364
|
+
// dedicated insertion verbs.
|
|
365
|
+
return {
|
|
366
|
+
result: currentLine,
|
|
367
|
+
matched: false,
|
|
368
|
+
error: `pattern ${JSON.stringify(spec.pattern)} matches an empty string; use \`pre\`/\`post\`/\`splice\` to insert or replace whole lines, or use a non-empty pattern`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
358
371
|
return { result: currentLine.replace(re, spec.replacement), matched: true };
|
|
359
372
|
}
|
|
360
373
|
// Fall back to literal substring match. Models frequently send sed patterns
|
|
@@ -544,31 +557,6 @@ function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
|
|
|
544
557
|
// Apply
|
|
545
558
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
546
559
|
|
|
547
|
-
function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: string[]): void {
|
|
548
|
-
const enabled = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "0";
|
|
549
|
-
if (!enabled) return;
|
|
550
|
-
for (const edit of edits) {
|
|
551
|
-
if (edit.op !== "splice" && edit.op !== "pre" && edit.op !== "post") continue;
|
|
552
|
-
if (edit.lines.length === 0) continue;
|
|
553
|
-
const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
|
|
554
|
-
if (!hasEscapedTabs) continue;
|
|
555
|
-
const hasRealTabs = edit.lines.some(line => line.includes("\t"));
|
|
556
|
-
if (hasRealTabs) continue;
|
|
557
|
-
let correctedCount = 0;
|
|
558
|
-
const corrected = edit.lines.map(line =>
|
|
559
|
-
line.replace(/^((?:\\t)+)/, escaped => {
|
|
560
|
-
correctedCount += escaped.length / 2;
|
|
561
|
-
return "\t".repeat(escaped.length / 2);
|
|
562
|
-
}),
|
|
563
|
-
);
|
|
564
|
-
if (correctedCount === 0) continue;
|
|
565
|
-
edit.lines = corrected;
|
|
566
|
-
warnings.push(
|
|
567
|
-
`Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
|
|
568
|
-
);
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
560
|
export interface AtomNoopEdit {
|
|
573
561
|
editIndex: number;
|
|
574
562
|
loc: string;
|
|
@@ -599,7 +587,6 @@ export function applyAtomEdits(
|
|
|
599
587
|
throw new HashlineMismatchError(mismatches, fileLines);
|
|
600
588
|
}
|
|
601
589
|
validateNoConflictingAnchorOps(edits);
|
|
602
|
-
maybeAutocorrectEscapedTabIndentation(edits, warnings);
|
|
603
590
|
|
|
604
591
|
const trackFirstChanged = (line: number) => {
|
|
605
592
|
if (firstChangedLine === undefined || line < firstChangedLine) {
|
|
@@ -674,7 +661,7 @@ export function applyAtomEdits(
|
|
|
674
661
|
case "sed": {
|
|
675
662
|
const { result, matched, error, literalFallback } = applySedToLine(currentLine, edit.spec);
|
|
676
663
|
if (error) {
|
|
677
|
-
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)}
|
|
664
|
+
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${error}`);
|
|
678
665
|
}
|
|
679
666
|
if (!matched) {
|
|
680
667
|
throw new Error(
|
|
@@ -786,9 +773,7 @@ export function applyAtomEdits(
|
|
|
786
773
|
}
|
|
787
774
|
if (!anyMatched) {
|
|
788
775
|
if (lastCompileError !== undefined) {
|
|
789
|
-
throw new Error(
|
|
790
|
-
`Edit sed expression ${JSON.stringify(edit.expression)} failed to compile: ${lastCompileError}`,
|
|
791
|
-
);
|
|
776
|
+
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${lastCompileError}`);
|
|
792
777
|
}
|
|
793
778
|
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} did not match any line in the file.`);
|
|
794
779
|
}
|
|
@@ -681,55 +681,6 @@ export function tryRebaseAnchor(
|
|
|
681
681
|
return found;
|
|
682
682
|
}
|
|
683
683
|
|
|
684
|
-
function isEscapedTabAutocorrectEnabled(): boolean {
|
|
685
|
-
switch (Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS) {
|
|
686
|
-
case "0":
|
|
687
|
-
return false;
|
|
688
|
-
case "1":
|
|
689
|
-
return true;
|
|
690
|
-
default:
|
|
691
|
-
return true;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function maybeAutocorrectEscapedTabIndentation(edits: HashlineEdit[], warnings: string[]): void {
|
|
696
|
-
if (!isEscapedTabAutocorrectEnabled()) return;
|
|
697
|
-
for (const edit of edits) {
|
|
698
|
-
if (edit.lines.length === 0) continue;
|
|
699
|
-
const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
|
|
700
|
-
if (!hasEscapedTabs) continue;
|
|
701
|
-
const hasRealTabs = edit.lines.some(line => line.includes("\t"));
|
|
702
|
-
if (hasRealTabs) continue;
|
|
703
|
-
let correctedCount = 0;
|
|
704
|
-
const corrected = edit.lines.map(line =>
|
|
705
|
-
line.replace(/^((?:\\t)+)/, escaped => {
|
|
706
|
-
correctedCount += escaped.length / 2;
|
|
707
|
-
return "\t".repeat(escaped.length / 2);
|
|
708
|
-
}),
|
|
709
|
-
);
|
|
710
|
-
if (correctedCount === 0) continue;
|
|
711
|
-
edit.lines = corrected;
|
|
712
|
-
warnings.push(
|
|
713
|
-
`Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
|
|
714
|
-
);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warnings: string[]): void {
|
|
719
|
-
for (const edit of edits) {
|
|
720
|
-
if (edit.lines.length === 0) continue;
|
|
721
|
-
if (!edit.lines.some(line => /\\uDDDD/i.test(line))) continue;
|
|
722
|
-
warnings.push(
|
|
723
|
-
`Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.`,
|
|
724
|
-
);
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
function runHashlinePreflightSanitizers(edits: HashlineEdit[], warnings: string[]): void {
|
|
729
|
-
maybeAutocorrectEscapedTabIndentation(edits, warnings);
|
|
730
|
-
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
|
|
731
|
-
}
|
|
732
|
-
|
|
733
684
|
function ensureHashlineEditHasContent(edit: HashlineEdit): void {
|
|
734
685
|
if (edit.lines.length === 0) {
|
|
735
686
|
edit.lines = [""];
|
|
@@ -1026,7 +977,6 @@ export function applyHashlineEdits(
|
|
|
1026
977
|
if (mismatches.length > 0) {
|
|
1027
978
|
throw new HashlineMismatchError(mismatches, fileLines);
|
|
1028
979
|
}
|
|
1029
|
-
runHashlinePreflightSanitizers(edits, warnings);
|
|
1030
980
|
for (const edit of edits) {
|
|
1031
981
|
collectBoundaryDuplicationWarning(edit, originalFileLines, warnings);
|
|
1032
982
|
}
|
package/src/edit/renderer.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Edit tool renderer and LSP batching helpers.
|
|
3
3
|
*/
|
|
4
|
+
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
|
4
5
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
5
6
|
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
|
6
7
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
@@ -157,32 +158,16 @@ function filePathFromEditEntry(p: string | undefined): string | undefined {
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
function decodePartialJsonStringFragment(fragment: string): string {
|
|
160
|
-
|
|
161
|
+
// Trim a trailing partial escape so JSON.parse sees a well-formed string.
|
|
162
|
+
let text = fragment.replace(/\\u[0-9a-fA-F]{0,3}$/, "");
|
|
161
163
|
const trailingBackslashes = text.match(/\\+$/)?.[0].length ?? 0;
|
|
162
|
-
if (trailingBackslashes % 2 === 1)
|
|
163
|
-
text = text.slice(0, -1);
|
|
164
|
-
}
|
|
164
|
+
if (trailingBackslashes % 2 === 1) text = text.slice(0, -1);
|
|
165
165
|
try {
|
|
166
166
|
return JSON.parse(`"${text}"`) as string;
|
|
167
167
|
} catch {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
switch (ch) {
|
|
172
|
-
case "b":
|
|
173
|
-
return "\b";
|
|
174
|
-
case "f":
|
|
175
|
-
return "\f";
|
|
176
|
-
case "n":
|
|
177
|
-
return "\n";
|
|
178
|
-
case "r":
|
|
179
|
-
return "\r";
|
|
180
|
-
case "t":
|
|
181
|
-
return "\t";
|
|
182
|
-
default:
|
|
183
|
-
return ch;
|
|
184
|
-
}
|
|
185
|
-
});
|
|
168
|
+
// Streaming fragment isn't a valid JSON string yet; surface it raw rather
|
|
169
|
+
// than ad-hoc unescaping that mishandles surrogates and partial escapes.
|
|
170
|
+
return text;
|
|
186
171
|
}
|
|
187
172
|
}
|
|
188
173
|
|
|
@@ -243,11 +228,11 @@ function formatEditDescription(
|
|
|
243
228
|
};
|
|
244
229
|
}
|
|
245
230
|
|
|
246
|
-
function renderPlainTextPreview(text: string, uiTheme: Theme): string {
|
|
247
|
-
const previewLines = text.split("\n");
|
|
231
|
+
function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string): string {
|
|
232
|
+
const previewLines = sanitizeText(text).split("\n");
|
|
248
233
|
let preview = "\n\n";
|
|
249
234
|
for (const line of previewLines.slice(0, CALL_TEXT_PREVIEW_LINES)) {
|
|
250
|
-
preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), CALL_TEXT_PREVIEW_WIDTH))}\n`;
|
|
235
|
+
preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line, filePath), CALL_TEXT_PREVIEW_WIDTH))}\n`;
|
|
251
236
|
}
|
|
252
237
|
if (previewLines.length > CALL_TEXT_PREVIEW_LINES) {
|
|
253
238
|
preview += uiTheme.fg("dim", `… ${previewLines.length - CALL_TEXT_PREVIEW_LINES} more lines`);
|
|
@@ -284,7 +269,7 @@ function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: T
|
|
|
284
269
|
if (!preview.diff && !preview.error) continue;
|
|
285
270
|
const header = uiTheme.fg("dim", `\n\n\u2500\u2500 ${shortenPath(preview.path)} \u2500\u2500`);
|
|
286
271
|
if (preview.error) {
|
|
287
|
-
parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`);
|
|
272
|
+
parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error, preview.path))}`);
|
|
288
273
|
continue;
|
|
289
274
|
}
|
|
290
275
|
if (preview.diff) {
|
|
@@ -311,10 +296,10 @@ function getCallPreview(
|
|
|
311
296
|
return formatStreamingDiff(args.diff, rawPath, uiTheme);
|
|
312
297
|
}
|
|
313
298
|
if (args.diff) {
|
|
314
|
-
return renderPlainTextPreview(args.diff, uiTheme);
|
|
299
|
+
return renderPlainTextPreview(args.diff, uiTheme, rawPath);
|
|
315
300
|
}
|
|
316
301
|
if (args.newText || args.patch) {
|
|
317
|
-
return renderPlainTextPreview(args.newText ?? args.patch ?? "", uiTheme);
|
|
302
|
+
return renderPlainTextPreview(args.newText ?? args.patch ?? "", uiTheme, rawPath);
|
|
318
303
|
}
|
|
319
304
|
return "";
|
|
320
305
|
}
|
|
@@ -438,7 +423,7 @@ export const editToolRenderer = {
|
|
|
438
423
|
}
|
|
439
424
|
text += getCallPreview(editArgs, rawPath, uiTheme, renderContext);
|
|
440
425
|
if (applyPatchSummary?.error) {
|
|
441
|
-
text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), CALL_TEXT_PREVIEW_WIDTH))}`;
|
|
426
|
+
text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), CALL_TEXT_PREVIEW_WIDTH))}`;
|
|
442
427
|
}
|
|
443
428
|
|
|
444
429
|
return new Text(text, 0, 0);
|
|
@@ -529,13 +514,13 @@ function renderSingleFileResult(
|
|
|
529
514
|
|
|
530
515
|
if (isError) {
|
|
531
516
|
if (errorText) {
|
|
532
|
-
text += `\n\n${uiTheme.fg("error", replaceTabs(errorText))}`;
|
|
517
|
+
text += `\n\n${uiTheme.fg("error", replaceTabs(errorText, rawPath))}`;
|
|
533
518
|
}
|
|
534
519
|
} else if (details?.diff) {
|
|
535
520
|
text += renderDiffSection(details.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
|
536
521
|
} else if (editDiffPreview) {
|
|
537
522
|
if ("error" in editDiffPreview) {
|
|
538
|
-
text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error))}`;
|
|
523
|
+
text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error, rawPath))}`;
|
|
539
524
|
} else if (editDiffPreview.diff) {
|
|
540
525
|
text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, renderDiffFn);
|
|
541
526
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
|
1
2
|
import { getIndentation } from "@oh-my-pi/pi-utils";
|
|
2
3
|
import * as Diff from "diff";
|
|
3
4
|
import { theme } from "../../modes/theme/theme";
|
|
@@ -15,7 +16,7 @@ const DIM_OFF = "\x1b[22m";
|
|
|
15
16
|
*/
|
|
16
17
|
function visualizeIndent(text: string, filePath?: string): string {
|
|
17
18
|
const match = text.match(/^([ \t]+)/);
|
|
18
|
-
if (!match) return replaceTabs(text);
|
|
19
|
+
if (!match) return replaceTabs(text, filePath);
|
|
19
20
|
const indent = match[1];
|
|
20
21
|
const rest = text.slice(indent.length);
|
|
21
22
|
const tabWidth = getIndentation(filePath);
|
|
@@ -30,7 +31,7 @@ function visualizeIndent(text: string, filePath?: string): string {
|
|
|
30
31
|
visible += `${DIM}·${DIM_OFF}`;
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
|
-
return `${visible}${replaceTabs(rest)}`;
|
|
34
|
+
return `${visible}${replaceTabs(rest, filePath)}`;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -106,7 +107,7 @@ export interface RenderDiffOptions {
|
|
|
106
107
|
* - Added lines: green, with inverse on changed tokens
|
|
107
108
|
*/
|
|
108
109
|
export function renderDiff(diffText: string, options: RenderDiffOptions = {}): string {
|
|
109
|
-
const lines = diffText.split("\n");
|
|
110
|
+
const lines = sanitizeText(diffText).split("\n");
|
|
110
111
|
const result: string[] = [];
|
|
111
112
|
const parsedLines = lines.map(parseDiffLine);
|
|
112
113
|
const lineNumberWidth = parsedLines.reduce((width, parsed) => {
|
|
@@ -138,7 +139,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
|
|
|
138
139
|
|
|
139
140
|
if (!parsed) {
|
|
140
141
|
prevLineNum = "";
|
|
141
|
-
result.push(theme.fg("toolDiffContext", line));
|
|
142
|
+
result.push(theme.fg("toolDiffContext", replaceTabs(line, options.filePath)));
|
|
142
143
|
i++;
|
|
143
144
|
continue;
|
|
144
145
|
}
|
|
@@ -172,12 +172,18 @@ export class EventController {
|
|
|
172
172
|
const signature = `${textContent}\u0000${imageCount}`;
|
|
173
173
|
|
|
174
174
|
this.#resetReadGroup();
|
|
175
|
-
|
|
175
|
+
const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
|
|
176
|
+
if (!wasOptimistic) {
|
|
176
177
|
this.ctx.addMessageToChat(event.message);
|
|
177
178
|
}
|
|
178
179
|
this.ctx.optimisticUserMessageSignature = undefined;
|
|
179
180
|
|
|
180
|
-
|
|
181
|
+
// Clear the editor only when the submission did not originate from this
|
|
182
|
+
// session's optimistic flow (which already cleared the editor at submit
|
|
183
|
+
// time). Clearing here on the optimistic path would race with the user
|
|
184
|
+
// typing the next prompt while the previous large redraw lands and erase
|
|
185
|
+
// their in-progress draft (#783).
|
|
186
|
+
if (!event.message.synthetic && !wasOptimistic) {
|
|
181
187
|
this.ctx.editor.setText("");
|
|
182
188
|
this.ctx.updatePendingMessagesDisplay();
|
|
183
189
|
}
|
package/src/tools/browser.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
Page,
|
|
16
16
|
default as Puppeteer,
|
|
17
17
|
SerializedAXNode,
|
|
18
|
-
} from "puppeteer";
|
|
18
|
+
} from "puppeteer-core";
|
|
19
19
|
import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
|
|
20
20
|
import type { ToolSession } from "../sdk";
|
|
21
21
|
import { resizeImage } from "../utils/image-resize";
|
|
@@ -53,7 +53,7 @@ async function loadPuppeteer(): Promise<typeof Puppeteer> {
|
|
|
53
53
|
await Bun.write(path.join(safeDir, "package.json"), "{}");
|
|
54
54
|
try {
|
|
55
55
|
process.chdir(safeDir);
|
|
56
|
-
puppeteerModule = (await import("puppeteer")).default;
|
|
56
|
+
puppeteerModule = (await import("puppeteer-core")).default;
|
|
57
57
|
return puppeteerModule;
|
|
58
58
|
} finally {
|
|
59
59
|
process.chdir(prev);
|
|
@@ -61,50 +61,172 @@ async function loadPuppeteer(): Promise<typeof Puppeteer> {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
64
|
+
* Lazily download Chromium on first browser launch via @puppeteer/browsers.
|
|
65
|
+
* Skipped when a system Chromium (NixOS) or PUPPETEER_EXECUTABLE_PATH is set.
|
|
66
|
+
* The browser is cached under ~/.omp/puppeteer (getPuppeteerDir).
|
|
67
|
+
*/
|
|
68
|
+
let chromiumExecutablePromise: Promise<string | undefined> | undefined;
|
|
69
|
+
async function ensureChromiumExecutable(): Promise<string | undefined> {
|
|
70
|
+
const sysChrome = resolveSystemChromium();
|
|
71
|
+
if (sysChrome) return sysChrome;
|
|
72
|
+
const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
73
|
+
if (envPath) return envPath;
|
|
74
|
+
if (chromiumExecutablePromise) return chromiumExecutablePromise;
|
|
75
|
+
|
|
76
|
+
chromiumExecutablePromise = (async () => {
|
|
77
|
+
const [browsers, revisions] = await Promise.all([
|
|
78
|
+
import("@puppeteer/browsers"),
|
|
79
|
+
import("puppeteer-core/internal/revisions.js"),
|
|
80
|
+
]);
|
|
81
|
+
const platform = browsers.detectBrowserPlatform();
|
|
82
|
+
if (!platform) {
|
|
83
|
+
logger.warn("Could not detect browser platform; relying on puppeteer default resolution");
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const cacheDir = getPuppeteerDir();
|
|
87
|
+
const buildId = await browsers.resolveBuildId(
|
|
88
|
+
browsers.Browser.CHROME,
|
|
89
|
+
platform,
|
|
90
|
+
revisions.PUPPETEER_REVISIONS.chrome,
|
|
91
|
+
);
|
|
92
|
+
const executablePath = browsers.computeExecutablePath({
|
|
93
|
+
browser: browsers.Browser.CHROME,
|
|
94
|
+
buildId,
|
|
95
|
+
cacheDir,
|
|
96
|
+
platform,
|
|
97
|
+
});
|
|
98
|
+
if (fs.existsSync(executablePath)) return executablePath;
|
|
99
|
+
|
|
100
|
+
logger.warn("Downloading Chromium for puppeteer (first browser use)", {
|
|
101
|
+
buildId,
|
|
102
|
+
platform,
|
|
103
|
+
cacheDir,
|
|
104
|
+
});
|
|
105
|
+
let lastReportedPercent = -1;
|
|
106
|
+
await browsers.install({
|
|
107
|
+
browser: browsers.Browser.CHROME,
|
|
108
|
+
buildId,
|
|
109
|
+
cacheDir,
|
|
110
|
+
platform,
|
|
111
|
+
downloadProgressCallback: (downloaded, total) => {
|
|
112
|
+
if (total <= 0) return;
|
|
113
|
+
const pct = Math.floor((downloaded / total) * 100);
|
|
114
|
+
if (pct >= lastReportedPercent + 10 || downloaded === total) {
|
|
115
|
+
lastReportedPercent = pct;
|
|
116
|
+
logger.debug(
|
|
117
|
+
`Chromium download: ${pct}% (${Math.round(downloaded / 1_000_000)} / ${Math.round(total / 1_000_000)} MB)`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
return executablePath;
|
|
123
|
+
})().catch(err => {
|
|
124
|
+
chromiumExecutablePromise = undefined;
|
|
125
|
+
throw new ToolError(
|
|
126
|
+
`Failed to install Chromium for puppeteer: ${(err as Error).message}. ` +
|
|
127
|
+
"Set PUPPETEER_EXECUTABLE_PATH to use an existing Chrome/Chromium binary, or install one manually.",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
return chromiumExecutablePromise;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve a system-installed Chrome/Chromium so `puppeteer.launch()` can reuse
|
|
135
|
+
* it instead of forcing a Chromium download. Returns `undefined` when no binary
|
|
136
|
+
* is found, which lets the caller fall back to a managed download.
|
|
67
137
|
*
|
|
68
|
-
* Detection order:
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
138
|
+
* Detection order (per platform):
|
|
139
|
+
* - macOS: Google Chrome → Chromium → Microsoft Edge (system + user Applications)
|
|
140
|
+
* - Linux: PATH lookups (google-chrome, chromium, etc.) → common /usr/bin paths,
|
|
141
|
+
* with NixOS-specific profile paths added when /etc/NIXOS exists
|
|
142
|
+
* - Windows: Program Files / LocalAppData install paths for Chrome and Edge
|
|
73
143
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
144
|
+
* Honored regardless of platform: PUPPETEER_EXECUTABLE_PATH callers should bypass
|
|
145
|
+
* this entirely (handled in ensureChromiumExecutable).
|
|
76
146
|
*/
|
|
77
147
|
let _resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
|
|
78
|
-
function
|
|
79
|
-
if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined;
|
|
148
|
+
function isExecutableFile(p: string): boolean {
|
|
80
149
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
150
|
+
const st = fs.statSync(p);
|
|
151
|
+
return st.isFile();
|
|
85
152
|
} catch {
|
|
86
|
-
|
|
87
|
-
return undefined;
|
|
153
|
+
return false;
|
|
88
154
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function systemChromiumCandidates(): string[] {
|
|
158
|
+
const home = os.homedir();
|
|
159
|
+
const candidates: string[] = [];
|
|
160
|
+
switch (process.platform) {
|
|
161
|
+
case "darwin": {
|
|
162
|
+
for (const root of ["/Applications", path.join(home, "Applications")]) {
|
|
163
|
+
candidates.push(
|
|
164
|
+
path.join(root, "Google Chrome.app/Contents/MacOS/Google Chrome"),
|
|
165
|
+
path.join(root, "Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"),
|
|
166
|
+
path.join(root, "Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev"),
|
|
167
|
+
path.join(root, "Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"),
|
|
168
|
+
path.join(root, "Chromium.app/Contents/MacOS/Chromium"),
|
|
169
|
+
path.join(root, "Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case "linux": {
|
|
175
|
+
const names = ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"];
|
|
176
|
+
for (const name of names) {
|
|
177
|
+
const found = $which(name);
|
|
178
|
+
if (found) candidates.push(found);
|
|
179
|
+
}
|
|
180
|
+
candidates.push(
|
|
181
|
+
"/usr/bin/google-chrome-stable",
|
|
182
|
+
"/usr/bin/google-chrome",
|
|
183
|
+
"/usr/bin/chromium",
|
|
184
|
+
"/usr/bin/chromium-browser",
|
|
185
|
+
"/snap/bin/chromium",
|
|
186
|
+
"/var/lib/flatpak/exports/bin/com.google.Chrome",
|
|
187
|
+
"/var/lib/flatpak/exports/bin/org.chromium.Chromium",
|
|
188
|
+
);
|
|
189
|
+
let onNixos = false;
|
|
97
190
|
try {
|
|
98
|
-
|
|
99
|
-
_resolvedChromium = candidate;
|
|
100
|
-
logger.debug("NixOS: using system Chromium", { path: candidate });
|
|
101
|
-
return candidate;
|
|
102
|
-
}
|
|
191
|
+
onNixos = fs.existsSync("/etc/NIXOS");
|
|
103
192
|
} catch {}
|
|
193
|
+
if (onNixos) {
|
|
194
|
+
candidates.push(path.join(home, ".nix-profile/bin/chromium"), "/run/current-system/sw/bin/chromium");
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case "win32": {
|
|
199
|
+
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
|
|
200
|
+
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
|
201
|
+
const localAppData = process.env.LOCALAPPDATA ?? path.join(home, "AppData\\Local");
|
|
202
|
+
candidates.push(
|
|
203
|
+
path.join(programFiles, "Google\\Chrome\\Application\\chrome.exe"),
|
|
204
|
+
path.join(programFilesX86, "Google\\Chrome\\Application\\chrome.exe"),
|
|
205
|
+
path.join(localAppData, "Google\\Chrome\\Application\\chrome.exe"),
|
|
206
|
+
path.join(programFiles, "Chromium\\Application\\chrome.exe"),
|
|
207
|
+
path.join(localAppData, "Chromium\\Application\\chrome.exe"),
|
|
208
|
+
path.join(programFiles, "Microsoft\\Edge\\Application\\msedge.exe"),
|
|
209
|
+
path.join(programFilesX86, "Microsoft\\Edge\\Application\\msedge.exe"),
|
|
210
|
+
);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return candidates;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function resolveSystemChromium(): string | undefined {
|
|
218
|
+
if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined;
|
|
219
|
+
const seen = new Set<string>();
|
|
220
|
+
for (const candidate of systemChromiumCandidates()) {
|
|
221
|
+
if (!candidate || seen.has(candidate)) continue;
|
|
222
|
+
seen.add(candidate);
|
|
223
|
+
if (isExecutableFile(candidate)) {
|
|
224
|
+
_resolvedChromium = candidate;
|
|
225
|
+
logger.debug("Using system Chrome/Chromium", { path: candidate });
|
|
226
|
+
return candidate;
|
|
104
227
|
}
|
|
105
228
|
}
|
|
106
229
|
_resolvedChromium = null;
|
|
107
|
-
logger.debug("NixOS detected but no Chromium binary found; Puppeteer may fail to launch");
|
|
108
230
|
return undefined;
|
|
109
231
|
}
|
|
110
232
|
|
|
@@ -674,7 +796,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
|
|
|
674
796
|
this.#browser = await puppeteer.launch({
|
|
675
797
|
headless: this.#currentHeadless,
|
|
676
798
|
defaultViewport: this.#currentHeadless ? initialViewport : null,
|
|
677
|
-
executablePath:
|
|
799
|
+
executablePath: await ensureChromiumExecutable(),
|
|
678
800
|
args: launchArgs,
|
|
679
801
|
ignoreDefaultArgs: [...STEALTH_IGNORE_DEFAULT_ARGS],
|
|
680
802
|
});
|
|
@@ -14,7 +14,7 @@ import { SearchProvider } from "./base";
|
|
|
14
14
|
import { findCredential, isApiKeyAvailable } from "./utils";
|
|
15
15
|
|
|
16
16
|
const ZAI_MCP_URL = "https://api.z.ai/api/mcp/web_search_prime/mcp";
|
|
17
|
-
const ZAI_TOOL_NAME = "
|
|
17
|
+
const ZAI_TOOL_NAME = "web_search_prime";
|
|
18
18
|
const DEFAULT_NUM_RESULTS = 10;
|
|
19
19
|
|
|
20
20
|
export interface ZaiSearchParams {
|