@trim21/personal-pi-extensions 0.0.152 → 0.0.153
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 +10 -1
- package/src/agents-md-user-message.ts +1 -1
- package/src/bash-default-timeout.ts +2 -2
- package/src/bwrap/index.ts +47 -60
- package/src/gh-readonly.ts +54 -46
- package/src/opencode-edit-engine.ts +76 -60
- package/src/opencode-edit.ts +14 -11
- package/src/opencode-read.ts +25 -21
- package/src/opencode-write.ts +7 -6
- package/src/todo-pendant.ts +6 -3
- package/src/workspace-guard.ts +12 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.153",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -34,8 +34,17 @@
|
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@earendil-works/pi-ai": "^0.80.6",
|
|
36
36
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
37
|
+
"@eslint/js": "10.0.1",
|
|
37
38
|
"@types/node": "^24.0.0",
|
|
39
|
+
"@typescript-eslint/utils": "8.66.0",
|
|
38
40
|
"eslint": "^10.8.0",
|
|
41
|
+
"eslint-config-prettier": "10.1.8",
|
|
42
|
+
"eslint-plugin-erasable-syntax-only": "0.4.2",
|
|
43
|
+
"eslint-plugin-promise": "7.3.0",
|
|
44
|
+
"eslint-plugin-simple-import-sort": "14.0.0",
|
|
45
|
+
"eslint-plugin-tsdoc": "0.5.2",
|
|
46
|
+
"eslint-plugin-unicorn": "70.0.0",
|
|
47
|
+
"eslint-plugin-unused-imports": "4.4.1",
|
|
39
48
|
"husky": "^9.1.7",
|
|
40
49
|
"lint-staged": "^17.0.8",
|
|
41
50
|
"prettier": "^3.6.0",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { getAgentDir, loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
|
|
16
|
-
export default function (pi: ExtensionAPI) {
|
|
16
|
+
export default function agentsMdUserMessage(pi: ExtensionAPI) {
|
|
17
17
|
let messageInjected = false;
|
|
18
18
|
|
|
19
19
|
pi.on("before_agent_start", (event) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
-
export default function (pi: ExtensionAPI) {
|
|
3
|
+
export default function bashDefaultTimeout(pi: ExtensionAPI) {
|
|
4
4
|
pi.on("tool_call", (event) => {
|
|
5
5
|
if (isToolCallEventType("bash", event) && event.input.timeout === undefined) {
|
|
6
6
|
event.input.timeout = 180;
|
package/src/bwrap/index.ts
CHANGED
|
@@ -52,22 +52,18 @@
|
|
|
52
52
|
* pi -e ./bwrap --no-bwrap
|
|
53
53
|
*/
|
|
54
54
|
|
|
55
|
-
import { spawn } from "node:child_process";
|
|
56
55
|
import type { ChildProcess } from "node:child_process";
|
|
56
|
+
import { spawn } from "node:child_process";
|
|
57
57
|
import { constants } from "node:fs";
|
|
58
|
+
import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
|
|
58
59
|
import { access as fsAccess } from "node:fs/promises";
|
|
59
|
-
import {
|
|
60
|
-
import { join, delimiter } from "node:path";
|
|
60
|
+
import { delimiter, join } from "node:path";
|
|
61
61
|
import { fileURLToPath } from "node:url";
|
|
62
|
+
|
|
63
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
64
|
+
import { type BashOperations, createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
62
65
|
import { Type } from "typebox";
|
|
63
66
|
import { Value } from "typebox/value";
|
|
64
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
65
|
-
import {
|
|
66
|
-
type BashOperations,
|
|
67
|
-
createBashTool,
|
|
68
|
-
getAgentDir,
|
|
69
|
-
Theme,
|
|
70
|
-
} from "@earendil-works/pi-coding-agent";
|
|
71
67
|
|
|
72
68
|
const SANDBOX_PROMPT = `
|
|
73
69
|
## Command Execution
|
|
@@ -110,31 +106,18 @@ and set \`request_full_access_reason\` to describe the failure.
|
|
|
110
106
|
|
|
111
107
|
const PROTECTED_DIRS = [".git", ".pi", ".agent"];
|
|
112
108
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
function findBwrap(override?: string): string {
|
|
116
|
-
if (override) {
|
|
117
|
-
if (existsSync(override)) return override;
|
|
118
|
-
throw new Error(`bwrap not found at configured path: ${override}`);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (bwrapPath) return bwrapPath;
|
|
109
|
+
const bwrapPath = findDefaultBwrap();
|
|
122
110
|
|
|
111
|
+
function findDefaultBwrap(): string {
|
|
123
112
|
const pathEnv = process.env.PATH ?? "";
|
|
124
113
|
for (const dir of pathEnv.split(delimiter)) {
|
|
125
114
|
const p = join(dir, "bwrap");
|
|
126
|
-
if (existsSync(p))
|
|
127
|
-
bwrapPath = p;
|
|
128
|
-
return p;
|
|
129
|
-
}
|
|
115
|
+
if (existsSync(p)) return p;
|
|
130
116
|
}
|
|
131
117
|
|
|
132
118
|
const candidates = ["/usr/bin/bwrap", "/usr/local/bin/bwrap", "/run/current-system/sw/bin/bwrap"];
|
|
133
119
|
for (const p of candidates) {
|
|
134
|
-
if (existsSync(p))
|
|
135
|
-
bwrapPath = p;
|
|
136
|
-
return p;
|
|
137
|
-
}
|
|
120
|
+
if (existsSync(p)) return p;
|
|
138
121
|
}
|
|
139
122
|
|
|
140
123
|
throw new Error(
|
|
@@ -145,6 +128,10 @@ function findBwrap(override?: string): string {
|
|
|
145
128
|
);
|
|
146
129
|
}
|
|
147
130
|
|
|
131
|
+
function findBwrap(override?: string): string {
|
|
132
|
+
return override ?? bwrapPath;
|
|
133
|
+
}
|
|
134
|
+
|
|
148
135
|
type BwrapMode = "allow-all" | "workspace-write" | "readonly";
|
|
149
136
|
|
|
150
137
|
interface BwrapConfig {
|
|
@@ -177,12 +164,15 @@ function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
|
177
164
|
extraArgs: config.extraArgs ?? [],
|
|
178
165
|
};
|
|
179
166
|
switch (config.mode) {
|
|
180
|
-
case "allow-all":
|
|
167
|
+
case "allow-all": {
|
|
181
168
|
return { ...base, bwrapEnabled: false, network: true };
|
|
182
|
-
|
|
169
|
+
}
|
|
170
|
+
case "workspace-write": {
|
|
183
171
|
return { ...base, bwrapEnabled: true, network: false };
|
|
184
|
-
|
|
172
|
+
}
|
|
173
|
+
case "readonly": {
|
|
185
174
|
return { ...base, bwrapEnabled: true, network: false, writablePaths: [] };
|
|
175
|
+
}
|
|
186
176
|
}
|
|
187
177
|
}
|
|
188
178
|
|
|
@@ -207,7 +197,8 @@ function deepMerge(base: BwrapConfig, overrides: Partial<BwrapConfig>): BwrapCon
|
|
|
207
197
|
|
|
208
198
|
function expandPath(p: string): string {
|
|
209
199
|
if (p.startsWith("~/")) {
|
|
210
|
-
const home = process.env.HOME
|
|
200
|
+
const home = process.env.HOME;
|
|
201
|
+
if (home === undefined) return p;
|
|
211
202
|
return join(home, p.slice(2));
|
|
212
203
|
}
|
|
213
204
|
return p;
|
|
@@ -232,9 +223,10 @@ function loadConfig(cwd: string): BwrapConfig {
|
|
|
232
223
|
] as const) {
|
|
233
224
|
if (existsSync(path)) {
|
|
234
225
|
try {
|
|
235
|
-
Object.assign(target, JSON.parse(readFileSync(path, "
|
|
236
|
-
} catch (
|
|
237
|
-
console
|
|
226
|
+
Object.assign(target, JSON.parse(readFileSync(path, "utf8")));
|
|
227
|
+
} catch (error) {
|
|
228
|
+
// eslint-disable-next-line no-console -- config parse warnings go to stderr
|
|
229
|
+
console.error(`Warning: Could not parse ${path}: ${String(error)}`);
|
|
238
230
|
}
|
|
239
231
|
}
|
|
240
232
|
}
|
|
@@ -316,7 +308,7 @@ function createBwrapBashOps(resolved: ResolvedBwrap): BashOperations {
|
|
|
316
308
|
// ── seccomp: block AF_UNIX + network syscalls ───────────────
|
|
317
309
|
// bwrap --unshare-net handles IP; seccomp closes the UNIX socket
|
|
318
310
|
// gap (Docker CLI, mysqld, etc.). Filter is generated once.
|
|
319
|
-
const seccompFd =
|
|
311
|
+
const seccompFd = resolved.network ? undefined : getSeccompFd();
|
|
320
312
|
|
|
321
313
|
const baseArgs: string[] = [
|
|
322
314
|
"--ro-bind",
|
|
@@ -331,8 +323,14 @@ function createBwrapBashOps(resolved: ResolvedBwrap): BashOperations {
|
|
|
331
323
|
|
|
332
324
|
// Two spawn paths so TypeScript can infer the correct child type.
|
|
333
325
|
const child: ChildProcess =
|
|
334
|
-
seccompFd
|
|
335
|
-
? spawn(
|
|
326
|
+
seccompFd === undefined
|
|
327
|
+
? spawn(findBwrap(resolved.bwrapPath), [...baseArgs, "--", "bash", "-c", command], {
|
|
328
|
+
cwd,
|
|
329
|
+
detached: true,
|
|
330
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
331
|
+
env: process.env,
|
|
332
|
+
})
|
|
333
|
+
: spawn(
|
|
336
334
|
findBwrap(resolved.bwrapPath),
|
|
337
335
|
[...baseArgs, "--seccomp", "3", "--", "bash", "-c", command],
|
|
338
336
|
{
|
|
@@ -341,13 +339,7 @@ function createBwrapBashOps(resolved: ResolvedBwrap): BashOperations {
|
|
|
341
339
|
stdio: ["ignore", "pipe", "pipe", seccompFd],
|
|
342
340
|
env: process.env,
|
|
343
341
|
},
|
|
344
|
-
)
|
|
345
|
-
: spawn(findBwrap(resolved.bwrapPath), [...baseArgs, "--", "bash", "-c", command], {
|
|
346
|
-
cwd,
|
|
347
|
-
detached: true,
|
|
348
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
349
|
-
env: process.env,
|
|
350
|
-
});
|
|
342
|
+
);
|
|
351
343
|
|
|
352
344
|
return new Promise((resolve, reject) => {
|
|
353
345
|
let timedOut = false;
|
|
@@ -393,7 +385,7 @@ function createBwrapBashOps(resolved: ResolvedBwrap): BashOperations {
|
|
|
393
385
|
// (potentially) exits. --die-with-parent already covers the
|
|
394
386
|
// case where the parent actually dies.
|
|
395
387
|
const forwardedSignals: NodeJS.Signals[] = ["SIGHUP", "SIGINT", "SIGTERM"];
|
|
396
|
-
const signalForwarders:
|
|
388
|
+
const signalForwarders: (() => void)[] = [];
|
|
397
389
|
|
|
398
390
|
for (const sig of forwardedSignals) {
|
|
399
391
|
const handler = () => {
|
|
@@ -445,10 +437,10 @@ function createBwrapBashOps(resolved: ResolvedBwrap): BashOperations {
|
|
|
445
437
|
|
|
446
438
|
function escapeHtml(text: string): string {
|
|
447
439
|
return text
|
|
448
|
-
.
|
|
449
|
-
.
|
|
450
|
-
.
|
|
451
|
-
.
|
|
440
|
+
.replaceAll("&", "&")
|
|
441
|
+
.replaceAll("<", "<")
|
|
442
|
+
.replaceAll(">", ">")
|
|
443
|
+
.replaceAll('"', """);
|
|
452
444
|
}
|
|
453
445
|
|
|
454
446
|
/**
|
|
@@ -469,7 +461,7 @@ function maxConsecutiveBackticks(text: string): number {
|
|
|
469
461
|
}
|
|
470
462
|
|
|
471
463
|
/**
|
|
472
|
-
* Wrap code in fenced code blocks
|
|
464
|
+
* Wrap code in fenced code blocks for literal plain-text rendering.
|
|
473
465
|
* Uses N+1 backticks for the fence where N is the longest consecutive
|
|
474
466
|
* backtick sequence in the code, so no escaping is needed.
|
|
475
467
|
*/
|
|
@@ -512,7 +504,7 @@ const sandboxedBashSchema = Type.Object({
|
|
|
512
504
|
),
|
|
513
505
|
});
|
|
514
506
|
|
|
515
|
-
export default function (pi: ExtensionAPI) {
|
|
507
|
+
export default function bwrapExtension(pi: ExtensionAPI) {
|
|
516
508
|
pi.registerFlag("no-bwrap", {
|
|
517
509
|
description: "Disable bwrap sandboxing for bash commands",
|
|
518
510
|
type: "boolean",
|
|
@@ -569,7 +561,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
569
561
|
let choice: string | undefined;
|
|
570
562
|
while (!choice) {
|
|
571
563
|
choice = await ctx.ui.select(desc, ["Approve once", "Block", "Block with reason"]);
|
|
572
|
-
if (
|
|
564
|
+
if (choice === undefined) {
|
|
573
565
|
ctx.abort();
|
|
574
566
|
throw new Error("User denied the command execution.");
|
|
575
567
|
}
|
|
@@ -625,9 +617,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
625
617
|
if (resolved.bwrapEnabled) {
|
|
626
618
|
try {
|
|
627
619
|
findBwrap(resolved.bwrapPath);
|
|
628
|
-
} catch (
|
|
620
|
+
} catch (error) {
|
|
629
621
|
resolved = null;
|
|
630
|
-
ctx.ui.notify(
|
|
622
|
+
ctx.ui.notify(error instanceof Error ? error.message : "bwrap not found", "error");
|
|
631
623
|
return;
|
|
632
624
|
}
|
|
633
625
|
}
|
|
@@ -689,13 +681,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
689
681
|
},
|
|
690
682
|
) {
|
|
691
683
|
setMode(mode);
|
|
692
|
-
const r = getResolved();
|
|
693
684
|
|
|
694
|
-
|
|
695
|
-
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${mode}`));
|
|
696
|
-
} else {
|
|
697
|
-
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${mode}`));
|
|
698
|
-
}
|
|
685
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${mode}`));
|
|
699
686
|
|
|
700
687
|
notifyMode(ctx, mode);
|
|
701
688
|
pi.sendMessage({
|
package/src/gh-readonly.ts
CHANGED
|
@@ -28,14 +28,15 @@
|
|
|
28
28
|
* cp gh-readonly.ts .pi/extensions/
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { spawn } from "node:child_process";
|
|
32
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
31
36
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
32
37
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
33
38
|
import { Type } from "typebox";
|
|
34
39
|
import { Value } from "typebox/value";
|
|
35
|
-
import { spawn } from "node:child_process";
|
|
36
|
-
import { homedir } from "node:os";
|
|
37
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
38
|
-
import { join } from "node:path";
|
|
39
40
|
|
|
40
41
|
interface GhResult {
|
|
41
42
|
stdout: string;
|
|
@@ -70,14 +71,16 @@ export function runGh(
|
|
|
70
71
|
let onAbort: (() => void) | undefined;
|
|
71
72
|
|
|
72
73
|
const killProcess = (reason: "timeout" | "abort") => {
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
killReason = reason;
|
|
76
|
-
proc.kill("SIGTERM");
|
|
77
|
-
setTimeout(() => {
|
|
78
|
-
if (!proc.killed) proc.kill("SIGKILL");
|
|
79
|
-
}, 5000);
|
|
74
|
+
if (killed) {
|
|
75
|
+
return;
|
|
80
76
|
}
|
|
77
|
+
|
|
78
|
+
killed = true;
|
|
79
|
+
killReason = reason;
|
|
80
|
+
proc.kill("SIGTERM");
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
83
|
+
}, 5000);
|
|
81
84
|
};
|
|
82
85
|
|
|
83
86
|
if (ctx.signal) {
|
|
@@ -214,7 +217,7 @@ function truncate(
|
|
|
214
217
|
maxBytes = 50 * 1024,
|
|
215
218
|
): { text: string; truncated: boolean } {
|
|
216
219
|
const lines = text.split("\n");
|
|
217
|
-
if (lines.length <= maxLines && Buffer.byteLength(text, "
|
|
220
|
+
if (lines.length <= maxLines && Buffer.byteLength(text, "utf8") <= maxBytes) {
|
|
218
221
|
return { text, truncated: false };
|
|
219
222
|
}
|
|
220
223
|
|
|
@@ -222,7 +225,7 @@ function truncate(
|
|
|
222
225
|
let bytes = 0;
|
|
223
226
|
for (const line of lines) {
|
|
224
227
|
if (out.length >= maxLines) break;
|
|
225
|
-
const lineBytes = Buffer.byteLength(line + "\n", "
|
|
228
|
+
const lineBytes = Buffer.byteLength(line + "\n", "utf8");
|
|
226
229
|
if (bytes + lineBytes > maxBytes) break;
|
|
227
230
|
out.push(line);
|
|
228
231
|
bytes += lineBytes;
|
|
@@ -238,13 +241,13 @@ function toToolResult(
|
|
|
238
241
|
stdout: string,
|
|
239
242
|
input?: unknown,
|
|
240
243
|
): {
|
|
241
|
-
content:
|
|
244
|
+
content: { type: "text"; text: string }[];
|
|
242
245
|
details: Record<string, unknown>;
|
|
243
246
|
} {
|
|
244
247
|
const { text, truncated } = truncate(stdout);
|
|
245
248
|
return {
|
|
246
249
|
content: [{ type: "text", text }],
|
|
247
|
-
details: { ...(input !== undefined
|
|
250
|
+
details: { ...(input !== undefined && { input }), truncated },
|
|
248
251
|
};
|
|
249
252
|
}
|
|
250
253
|
|
|
@@ -317,12 +320,12 @@ export interface JobInfo {
|
|
|
317
320
|
export function stepsDetail(
|
|
318
321
|
job: JobInfo,
|
|
319
322
|
expandedSteps?: Set<number>,
|
|
320
|
-
):
|
|
323
|
+
): { number: number; name: string; conclusion: string | null; expanded?: boolean }[] {
|
|
321
324
|
return job.steps.map((s) => ({
|
|
322
325
|
number: s.number,
|
|
323
326
|
name: s.name,
|
|
324
327
|
conclusion: s.conclusion,
|
|
325
|
-
...(expandedSteps?.has(s.number)
|
|
328
|
+
...(expandedSteps?.has(s.number) && { expanded: true }),
|
|
326
329
|
}));
|
|
327
330
|
}
|
|
328
331
|
|
|
@@ -348,7 +351,7 @@ async function getJobLog(
|
|
|
348
351
|
const fetchAndCache = async (): Promise<string> => {
|
|
349
352
|
// Check file cache
|
|
350
353
|
try {
|
|
351
|
-
return await readFile(cacheFile, "
|
|
354
|
+
return await readFile(cacheFile, "utf8");
|
|
352
355
|
} catch {
|
|
353
356
|
// Not cached, fetch from GitHub
|
|
354
357
|
}
|
|
@@ -400,20 +403,27 @@ async function resolveRepo(
|
|
|
400
403
|
|
|
401
404
|
export function statusIcon(conclusion: string | null): string {
|
|
402
405
|
switch (conclusion) {
|
|
403
|
-
case "success":
|
|
406
|
+
case "success": {
|
|
404
407
|
return "✅";
|
|
405
|
-
|
|
408
|
+
}
|
|
409
|
+
case "failure": {
|
|
406
410
|
return "❌";
|
|
407
|
-
|
|
411
|
+
}
|
|
412
|
+
case "cancelled": {
|
|
408
413
|
return "🚫";
|
|
409
|
-
|
|
414
|
+
}
|
|
415
|
+
case "skipped": {
|
|
410
416
|
return "⏭️";
|
|
411
|
-
|
|
417
|
+
}
|
|
418
|
+
case "timed_out": {
|
|
412
419
|
return "⏰";
|
|
413
|
-
|
|
420
|
+
}
|
|
421
|
+
case "action_required": {
|
|
414
422
|
return "⚠️";
|
|
415
|
-
|
|
423
|
+
}
|
|
424
|
+
default: {
|
|
416
425
|
return "🔄";
|
|
426
|
+
}
|
|
417
427
|
}
|
|
418
428
|
}
|
|
419
429
|
|
|
@@ -443,18 +453,16 @@ export function statusIcon(conclusion: string | null): string {
|
|
|
443
453
|
export function extractStepFromLog(
|
|
444
454
|
log: string,
|
|
445
455
|
stepNumber: number,
|
|
446
|
-
apiSteps:
|
|
456
|
+
apiSteps: { number: number; name: string }[],
|
|
447
457
|
): string | null {
|
|
448
|
-
|
|
449
|
-
if (!targetStep) return null;
|
|
458
|
+
if (apiSteps.every((s) => s.number !== stepNumber)) return null;
|
|
450
459
|
|
|
451
460
|
const lines = log.split("\n");
|
|
452
461
|
|
|
453
462
|
// Collect depth-1 "Run "/"Post Run " groups in log order.
|
|
454
|
-
const groups:
|
|
463
|
+
const groups: { line: number; action: string }[] = [];
|
|
455
464
|
let depth = 0;
|
|
456
|
-
for (
|
|
457
|
-
const line = lines[i];
|
|
465
|
+
for (const [i, line] of lines.entries()) {
|
|
458
466
|
if (line.includes("##[endgroup]")) {
|
|
459
467
|
if (depth > 0) depth--;
|
|
460
468
|
continue;
|
|
@@ -462,7 +470,7 @@ export function extractStepFromLog(
|
|
|
462
470
|
if (line.includes("##[group]")) {
|
|
463
471
|
depth++;
|
|
464
472
|
if (depth === 1) {
|
|
465
|
-
const m =
|
|
473
|
+
const m = /##\[group\](.*)/.exec(line);
|
|
466
474
|
const name = m ? m[1].trim() : "";
|
|
467
475
|
if (name.startsWith("Run ") || name.startsWith("Post Run ")) {
|
|
468
476
|
groups.push({ line: i, action: name.replace(/^(Run |Post Run )/, "").trim() });
|
|
@@ -483,7 +491,7 @@ export function extractStepFromLog(
|
|
|
483
491
|
const runSteps = apiSteps
|
|
484
492
|
.filter((s) => /^(Run |Post Run )/.test(s.name))
|
|
485
493
|
.map((s) => ({ number: s.number, action: s.name.replace(/^(Run |Post Run )/, "").trim() }))
|
|
486
|
-
.
|
|
494
|
+
.toSorted((a, b) => a.number - b.number);
|
|
487
495
|
|
|
488
496
|
// Greedily assign each run step the first unclaimed group whose action name
|
|
489
497
|
// matches (log order). Leftover groups are composite-action internals.
|
|
@@ -498,9 +506,9 @@ export function extractStepFromLog(
|
|
|
498
506
|
}
|
|
499
507
|
|
|
500
508
|
// Anchor sequence in log order.
|
|
501
|
-
const anchors = [...stepToGroup
|
|
509
|
+
const anchors = [...stepToGroup]
|
|
502
510
|
.map(([stepNum, gi]) => ({ stepNum, line: groups[gi].line }))
|
|
503
|
-
.
|
|
511
|
+
.toSorted((a, b) => a.line - b.line);
|
|
504
512
|
|
|
505
513
|
// Direct anchor hit: span from this anchor to the next one.
|
|
506
514
|
const anchorIdx = anchors.findIndex((a) => a.stepNum === stepNumber);
|
|
@@ -522,9 +530,8 @@ export function extractStepFromLog(
|
|
|
522
530
|
const spanStart = prevAnchor ? prevAnchor.line + 1 : 0;
|
|
523
531
|
const spanEnd = nextAnchor ? nextAnchor.line : lines.length;
|
|
524
532
|
|
|
525
|
-
for (
|
|
533
|
+
for (const [gi, g] of groups.entries()) {
|
|
526
534
|
if (used.has(gi)) continue;
|
|
527
|
-
const g = groups[gi];
|
|
528
535
|
if (g.line >= spanStart && g.line < spanEnd) {
|
|
529
536
|
return lines.slice(g.line, spanEnd).join("\n").trimEnd();
|
|
530
537
|
}
|
|
@@ -543,15 +550,16 @@ export interface CiLogsJob {
|
|
|
543
550
|
steps: StepInfo[];
|
|
544
551
|
}
|
|
545
552
|
|
|
546
|
-
export
|
|
547
|
-
content:
|
|
553
|
+
export interface CiLogsResult {
|
|
554
|
+
content: { type: "text"; text: string }[];
|
|
548
555
|
details: Record<string, unknown>;
|
|
549
|
-
}
|
|
556
|
+
}
|
|
550
557
|
|
|
551
558
|
/** GitHub Actions runner line prefix: `2026-08-05T16:35:50.8358826Z `. */
|
|
552
559
|
const RUNNER_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z /;
|
|
553
560
|
/** ANSI color escape sequences. */
|
|
554
|
-
|
|
561
|
+
// eslint-disable-next-line no-control-regex -- intentional: matching raw ESC sequences in runner logs
|
|
562
|
+
const ANSI_RE = /\u001B\[[0-9;]*m/g;
|
|
555
563
|
|
|
556
564
|
/**
|
|
557
565
|
* Strip the runner framing from a step's raw log, leaving the command's own
|
|
@@ -566,7 +574,7 @@ export function cleanStepOutput(stepLog: string): string {
|
|
|
566
574
|
line
|
|
567
575
|
.replace(/^\uFEFF/, "") // UTF-8 BOM on the first line
|
|
568
576
|
.replace(RUNNER_TIMESTAMP_RE, "")
|
|
569
|
-
.
|
|
577
|
+
.replaceAll(ANSI_RE, "")
|
|
570
578
|
.replace(/\r$/, "")
|
|
571
579
|
.trimEnd(),
|
|
572
580
|
)
|
|
@@ -605,7 +613,7 @@ export async function renderStepLog(
|
|
|
605
613
|
}
|
|
606
614
|
|
|
607
615
|
const isNumeric = /^\d+$/.test(job);
|
|
608
|
-
const targetJob = jobs.find((j) => (isNumeric ? String(j.id)
|
|
616
|
+
const targetJob = jobs.find((j) => (isNumeric ? String(j.id) : j.name) === job);
|
|
609
617
|
if (!targetJob) {
|
|
610
618
|
return {
|
|
611
619
|
content: [
|
|
@@ -765,7 +773,7 @@ export async function renderJobLogs(
|
|
|
765
773
|
let targetJobs = jobs;
|
|
766
774
|
if (job) {
|
|
767
775
|
const isNumeric = /^\d+$/.test(job);
|
|
768
|
-
targetJobs = jobs.filter((j) => (isNumeric ? String(j.id)
|
|
776
|
+
targetJobs = jobs.filter((j) => (isNumeric ? String(j.id) : j.name) === job);
|
|
769
777
|
if (targetJobs.length === 0) {
|
|
770
778
|
return {
|
|
771
779
|
content: [
|
|
@@ -816,7 +824,7 @@ export async function renderJobLogs(
|
|
|
816
824
|
}
|
|
817
825
|
|
|
818
826
|
const { text } = truncate(logToShow, limit ?? 500, 60 * 1024);
|
|
819
|
-
steps.push({ name: s.name, ...(text
|
|
827
|
+
steps.push({ name: s.name, ...(text && { output: text }) });
|
|
820
828
|
} catch {
|
|
821
829
|
// Log fetch failed — list the step without an output.
|
|
822
830
|
steps.push({ name: s.name });
|
|
@@ -849,7 +857,7 @@ export async function renderJobLogs(
|
|
|
849
857
|
|
|
850
858
|
// ── tools ────────────────────────────────────────────────────────────────────
|
|
851
859
|
|
|
852
|
-
export default function (pi: ExtensionAPI) {
|
|
860
|
+
export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
853
861
|
// ── read-github-issue ──────────────────────────────────────────────────────
|
|
854
862
|
pi.registerTool({
|
|
855
863
|
name: "read-github-issue",
|
|
@@ -70,14 +70,14 @@ const SimpleReplacer: Replacer = function* (_content, find) {
|
|
|
70
70
|
const LineTrimmedReplacer: Replacer = function* (content, find) {
|
|
71
71
|
const originalLines = content.split("\n");
|
|
72
72
|
const searchLines = find.split("\n");
|
|
73
|
-
if (searchLines
|
|
73
|
+
if (searchLines.at(-1) === "") {
|
|
74
74
|
searchLines.pop();
|
|
75
75
|
}
|
|
76
76
|
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
|
77
77
|
let matches = true;
|
|
78
|
-
for (
|
|
78
|
+
for (const [j, searchLine] of searchLines.entries()) {
|
|
79
79
|
const originalTrimmed = originalLines[i + j].trim();
|
|
80
|
-
const searchTrimmed =
|
|
80
|
+
const searchTrimmed = searchLine.trim();
|
|
81
81
|
if (originalTrimmed !== searchTrimmed) {
|
|
82
82
|
matches = false;
|
|
83
83
|
break;
|
|
@@ -95,7 +95,7 @@ const LineTrimmedReplacer: Replacer = function* (content, find) {
|
|
|
95
95
|
matchEndIndex += 1;
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
-
yield content.
|
|
98
|
+
yield content.slice(matchStartIndex, matchEndIndex);
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
};
|
|
@@ -106,15 +106,15 @@ const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
|
106
106
|
if (searchLines.length < 3) {
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
|
-
if (searchLines
|
|
109
|
+
if (searchLines.at(-1) === "") {
|
|
110
110
|
searchLines.pop();
|
|
111
111
|
}
|
|
112
112
|
const firstLineSearch = searchLines[0].trim();
|
|
113
|
-
const lastLineSearch = searchLines
|
|
113
|
+
const lastLineSearch = searchLines.at(-1)?.trim() ?? "";
|
|
114
114
|
const searchBlockSize = searchLines.length;
|
|
115
115
|
const maxLineDelta = Math.max(1, Math.floor(searchBlockSize * 0.25));
|
|
116
116
|
|
|
117
|
-
const candidates:
|
|
117
|
+
const candidates: { startLine: number; endLine: number }[] = [];
|
|
118
118
|
for (let i = 0; i < originalLines.length; i++) {
|
|
119
119
|
if (originalLines[i].trim() !== firstLineSearch) {
|
|
120
120
|
continue;
|
|
@@ -153,7 +153,7 @@ const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
} else {
|
|
156
|
-
similarity = 1
|
|
156
|
+
similarity = 1;
|
|
157
157
|
}
|
|
158
158
|
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
|
159
159
|
let matchStartIndex = 0;
|
|
@@ -167,7 +167,7 @@ const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
|
167
167
|
matchEndIndex += 1;
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
-
yield content.
|
|
170
|
+
yield content.slice(matchStartIndex, matchEndIndex);
|
|
171
171
|
}
|
|
172
172
|
return;
|
|
173
173
|
}
|
|
@@ -192,7 +192,7 @@ const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
|
192
192
|
}
|
|
193
193
|
similarity /= linesToCheck;
|
|
194
194
|
} else {
|
|
195
|
-
similarity = 1
|
|
195
|
+
similarity = 1;
|
|
196
196
|
}
|
|
197
197
|
if (similarity > maxSimilarity) {
|
|
198
198
|
maxSimilarity = similarity;
|
|
@@ -212,16 +212,18 @@ const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
|
212
212
|
matchEndIndex += 1;
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
|
-
yield content.
|
|
215
|
+
yield content.slice(matchStartIndex, matchEndIndex);
|
|
216
216
|
}
|
|
217
217
|
};
|
|
218
218
|
|
|
219
|
+
function normalizeWhitespace(text: string): string {
|
|
220
|
+
return text.replaceAll(/\s+/g, " ").trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
219
223
|
const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
|
220
|
-
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
|
|
221
224
|
const normalizedFind = normalizeWhitespace(find);
|
|
222
225
|
const lines = content.split("\n");
|
|
223
|
-
for (
|
|
224
|
-
const line = lines[i];
|
|
226
|
+
for (const line of lines) {
|
|
225
227
|
if (normalizeWhitespace(line) === normalizedFind) {
|
|
226
228
|
yield line;
|
|
227
229
|
} else {
|
|
@@ -230,8 +232,8 @@ const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
|
|
230
232
|
const words = find.trim().split(/\s+/);
|
|
231
233
|
if (words.length > 0) {
|
|
232
234
|
const pattern = words
|
|
233
|
-
.map((word) => word.
|
|
234
|
-
.join(
|
|
235
|
+
.map((word) => word.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
|
|
236
|
+
.join(String.raw`\s+`);
|
|
235
237
|
try {
|
|
236
238
|
const regex = new RegExp(pattern);
|
|
237
239
|
const match = line.match(regex);
|
|
@@ -256,21 +258,20 @@ const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
|
|
256
258
|
}
|
|
257
259
|
};
|
|
258
260
|
|
|
261
|
+
function removeIndentation(text: string): string {
|
|
262
|
+
const lines = text.split("\n");
|
|
263
|
+
const nonEmptyLines = lines.filter((line) => line.trim().length > 0);
|
|
264
|
+
if (nonEmptyLines.length === 0) return text;
|
|
265
|
+
const minIndent = Math.min(
|
|
266
|
+
...nonEmptyLines.map((line) => {
|
|
267
|
+
const match = /^(\s*)/.exec(line);
|
|
268
|
+
return match ? match[1].length : 0;
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n");
|
|
272
|
+
}
|
|
273
|
+
|
|
259
274
|
const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
|
260
|
-
const removeIndentation = (text: string) => {
|
|
261
|
-
const lines = text.split("\n");
|
|
262
|
-
const nonEmptyLines = lines.filter((line) => line.trim().length > 0);
|
|
263
|
-
if (nonEmptyLines.length === 0) return text;
|
|
264
|
-
const minIndent = Math.min(
|
|
265
|
-
...nonEmptyLines.map((line) => {
|
|
266
|
-
const match = line.match(/^(\s*)/);
|
|
267
|
-
return match ? match[1].length : 0;
|
|
268
|
-
}),
|
|
269
|
-
);
|
|
270
|
-
return lines
|
|
271
|
-
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
|
|
272
|
-
.join("\n");
|
|
273
|
-
};
|
|
274
275
|
const normalizedFind = removeIndentation(find);
|
|
275
276
|
const contentLines = content.split("\n");
|
|
276
277
|
const findLines = find.split("\n");
|
|
@@ -282,33 +283,44 @@ const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
|
|
282
283
|
}
|
|
283
284
|
};
|
|
284
285
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return "\\";
|
|
303
|
-
case "\n":
|
|
304
|
-
return "\n";
|
|
305
|
-
case "$":
|
|
306
|
-
return "$";
|
|
307
|
-
default:
|
|
308
|
-
return _match;
|
|
286
|
+
function unescapeString(str: string): string {
|
|
287
|
+
return str.replaceAll(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (_match, capturedChar) => {
|
|
288
|
+
switch (capturedChar) {
|
|
289
|
+
case "n": {
|
|
290
|
+
return "\n";
|
|
291
|
+
}
|
|
292
|
+
case "t": {
|
|
293
|
+
return "\t";
|
|
294
|
+
}
|
|
295
|
+
case "r": {
|
|
296
|
+
return "\r";
|
|
297
|
+
}
|
|
298
|
+
case "'": {
|
|
299
|
+
return "'";
|
|
300
|
+
}
|
|
301
|
+
case '"': {
|
|
302
|
+
return '"';
|
|
309
303
|
}
|
|
310
|
-
|
|
311
|
-
|
|
304
|
+
case "`": {
|
|
305
|
+
return "`";
|
|
306
|
+
}
|
|
307
|
+
case "\\": {
|
|
308
|
+
return "\\";
|
|
309
|
+
}
|
|
310
|
+
case "\n": {
|
|
311
|
+
return "\n";
|
|
312
|
+
}
|
|
313
|
+
case "$": {
|
|
314
|
+
return "$";
|
|
315
|
+
}
|
|
316
|
+
default: {
|
|
317
|
+
return _match;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
|
312
324
|
const unescapedFind = unescapeString(find);
|
|
313
325
|
if (content.includes(unescapedFind)) {
|
|
314
326
|
yield unescapedFind;
|
|
@@ -357,12 +369,12 @@ const ContextAwareReplacer: Replacer = function* (content, find) {
|
|
|
357
369
|
if (findLines.length < 3) {
|
|
358
370
|
return;
|
|
359
371
|
}
|
|
360
|
-
if (findLines
|
|
372
|
+
if (findLines.at(-1) === "") {
|
|
361
373
|
findLines.pop();
|
|
362
374
|
}
|
|
363
375
|
const contentLines = content.split("\n");
|
|
364
376
|
const firstLine = findLines[0].trim();
|
|
365
|
-
const lastLine = findLines
|
|
377
|
+
const lastLine = findLines.at(-1)?.trim() ?? "";
|
|
366
378
|
for (let i = 0; i < contentLines.length; i++) {
|
|
367
379
|
if (contentLines[i].trim() !== firstLine) continue;
|
|
368
380
|
for (let j = i + 2; j < contentLines.length; j++) {
|
|
@@ -446,11 +458,15 @@ export function replace(
|
|
|
446
458
|
);
|
|
447
459
|
}
|
|
448
460
|
if (replaceAll) {
|
|
449
|
-
return content.replaceAll(search, newString);
|
|
461
|
+
return content.replaceAll(search, () => newString);
|
|
450
462
|
}
|
|
451
463
|
const lastIndex = content.lastIndexOf(search);
|
|
452
464
|
if (index !== lastIndex) continue;
|
|
453
|
-
return
|
|
465
|
+
return (
|
|
466
|
+
content.slice(0, Math.max(0, index)) +
|
|
467
|
+
newString +
|
|
468
|
+
content.slice(Math.max(0, index + search.length))
|
|
469
|
+
);
|
|
454
470
|
}
|
|
455
471
|
}
|
|
456
472
|
|
package/src/opencode-edit.ts
CHANGED
|
@@ -9,12 +9,18 @@
|
|
|
9
9
|
* pi -e ./opencode-edit.ts
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { constants } from "node:fs";
|
|
13
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
14
|
+
import { isAbsolute, resolve } from "node:path";
|
|
15
|
+
|
|
12
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
17
|
import {
|
|
14
18
|
generateDiffString,
|
|
15
19
|
generateUnifiedPatch,
|
|
16
20
|
withFileMutationQueue,
|
|
17
21
|
} from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { Type } from "typebox";
|
|
23
|
+
|
|
18
24
|
import {
|
|
19
25
|
detectLineEnding,
|
|
20
26
|
normalizeToLF,
|
|
@@ -22,10 +28,6 @@ import {
|
|
|
22
28
|
restoreLineEndings,
|
|
23
29
|
stripBom,
|
|
24
30
|
} from "./opencode-edit-engine.js";
|
|
25
|
-
import { constants } from "fs";
|
|
26
|
-
import { access, readFile, writeFile } from "fs/promises";
|
|
27
|
-
import { isAbsolute, resolve } from "path";
|
|
28
|
-
import { Type } from "typebox";
|
|
29
31
|
|
|
30
32
|
// ── schema ────────────────────────────────────────────────────────────────────
|
|
31
33
|
|
|
@@ -42,7 +44,7 @@ const editSchema = Type.Object({
|
|
|
42
44
|
|
|
43
45
|
// ── extension ─────────────────────────────────────────────────────────────────
|
|
44
46
|
|
|
45
|
-
export default function (pi: ExtensionAPI) {
|
|
47
|
+
export default function opencodeEdit(pi: ExtensionAPI) {
|
|
46
48
|
pi.registerTool({
|
|
47
49
|
name: "edit",
|
|
48
50
|
label: "edit",
|
|
@@ -69,10 +71,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
69
71
|
|
|
70
72
|
const absolutePath = isAbsolute(filePath) ? filePath : resolve(ctx.cwd, filePath);
|
|
71
73
|
|
|
74
|
+
const throwIfAborted = (): void => {
|
|
75
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
76
|
+
};
|
|
77
|
+
|
|
72
78
|
return withFileMutationQueue(absolutePath, async () => {
|
|
73
|
-
const throwIfAborted = (): void => {
|
|
74
|
-
if (signal?.aborted) throw new Error("Operation aborted");
|
|
75
|
-
};
|
|
76
79
|
throwIfAborted();
|
|
77
80
|
|
|
78
81
|
try {
|
|
@@ -83,12 +86,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
83
86
|
error instanceof Error && "code" in error && typeof error.code === "string"
|
|
84
87
|
? `Error code: ${error.code}`
|
|
85
88
|
: String(error);
|
|
86
|
-
throw new Error(`Could not edit file: ${filePath}. ${msg}
|
|
89
|
+
throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
|
|
87
90
|
}
|
|
88
91
|
throwIfAborted();
|
|
89
92
|
|
|
90
93
|
const buffer = await readFile(absolutePath);
|
|
91
|
-
const rawContent = buffer.toString("
|
|
94
|
+
const rawContent = buffer.toString("utf8");
|
|
92
95
|
throwIfAborted();
|
|
93
96
|
|
|
94
97
|
// Strip BOM then normalize line endings to LF.
|
|
@@ -101,7 +104,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
101
104
|
throwIfAborted();
|
|
102
105
|
|
|
103
106
|
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
|
104
|
-
await writeFile(absolutePath, finalContent, "
|
|
107
|
+
await writeFile(absolutePath, finalContent, "utf8");
|
|
105
108
|
throwIfAborted();
|
|
106
109
|
|
|
107
110
|
const diffResult = generateDiffString(normalizedContent, newContent);
|
package/src/opencode-read.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { constants } from "node:fs";
|
|
25
25
|
import { access, open, readdir, readFile, stat } from "node:fs/promises";
|
|
26
26
|
import { basename, dirname, isAbsolute, resolve as resolvePath, sep } from "node:path";
|
|
27
|
+
|
|
27
28
|
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
28
29
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
29
30
|
import { Type } from "typebox";
|
|
@@ -71,10 +72,10 @@ const BINARY_EXTENSIONS = new Set([
|
|
|
71
72
|
".pyo",
|
|
72
73
|
]);
|
|
73
74
|
|
|
74
|
-
const IMAGE_SIGNATURES:
|
|
75
|
+
const IMAGE_SIGNATURES: {
|
|
75
76
|
signature: Uint8Array | ((buf: Uint8Array) => boolean);
|
|
76
77
|
mimeType: string;
|
|
77
|
-
}
|
|
78
|
+
}[] = [
|
|
78
79
|
{ signature: new Uint8Array([0xff, 0xd8, 0xff]), mimeType: "image/jpeg" },
|
|
79
80
|
{
|
|
80
81
|
signature: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
@@ -108,7 +109,7 @@ const IMAGE_SIGNATURES: Array<{
|
|
|
108
109
|
function startsWithAscii(buf: Uint8Array, offset: number, text: string): boolean {
|
|
109
110
|
if (buf.length < offset + text.length) return false;
|
|
110
111
|
for (let i = 0; i < text.length; i++) {
|
|
111
|
-
if (buf[offset + i] !== text.
|
|
112
|
+
if (buf[offset + i] !== text.codePointAt(i)) return false;
|
|
112
113
|
}
|
|
113
114
|
return true;
|
|
114
115
|
}
|
|
@@ -144,7 +145,7 @@ async function detectImageMimeTypeFromFile(filePath: string): Promise<string | n
|
|
|
144
145
|
|
|
145
146
|
function isBinaryExtension(filePath: string): boolean {
|
|
146
147
|
const dotIndex = filePath.lastIndexOf(".");
|
|
147
|
-
if (dotIndex
|
|
148
|
+
if (dotIndex === -1) return false;
|
|
148
149
|
return BINARY_EXTENSIONS.has(filePath.slice(dotIndex).toLowerCase());
|
|
149
150
|
}
|
|
150
151
|
|
|
@@ -186,7 +187,7 @@ function truncateHead(
|
|
|
186
187
|
const lines = content ? content.split("\n") : [];
|
|
187
188
|
if (content.endsWith("\n")) lines.pop();
|
|
188
189
|
const totalLines = lines.length;
|
|
189
|
-
const totalBytes = Buffer.byteLength(content, "
|
|
190
|
+
const totalBytes = Buffer.byteLength(content, "utf8");
|
|
190
191
|
|
|
191
192
|
if (totalLines <= maxLines && totalBytes <= maxBytes) {
|
|
192
193
|
return {
|
|
@@ -204,7 +205,7 @@ function truncateHead(
|
|
|
204
205
|
};
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
const firstLineBytes = lines.length > 0 ? Buffer.byteLength(lines[0], "
|
|
208
|
+
const firstLineBytes = lines.length > 0 ? Buffer.byteLength(lines[0], "utf8") : 0;
|
|
208
209
|
if (firstLineBytes > maxBytes) {
|
|
209
210
|
return {
|
|
210
211
|
content: "",
|
|
@@ -226,7 +227,7 @@ function truncateHead(
|
|
|
226
227
|
let truncatedBy: "lines" | "bytes" = "lines";
|
|
227
228
|
|
|
228
229
|
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
|
229
|
-
const lineBytes = Buffer.byteLength(lines[i], "
|
|
230
|
+
const lineBytes = Buffer.byteLength(lines[i], "utf8") + (i > 0 ? 1 : 0);
|
|
230
231
|
if (outputBytesCount + lineBytes > maxBytes) {
|
|
231
232
|
truncatedBy = "bytes";
|
|
232
233
|
break;
|
|
@@ -247,7 +248,7 @@ function truncateHead(
|
|
|
247
248
|
totalLines,
|
|
248
249
|
totalBytes,
|
|
249
250
|
outputLines: outputLinesArr.length,
|
|
250
|
-
outputBytes: Buffer.byteLength(outputContent, "
|
|
251
|
+
outputBytes: Buffer.byteLength(outputContent, "utf8"),
|
|
251
252
|
lastLinePartial: false,
|
|
252
253
|
firstLineExceedsLimit: false,
|
|
253
254
|
maxLines,
|
|
@@ -300,7 +301,7 @@ async function formatDirectoryEntries(dirPath: string): Promise<string[]> {
|
|
|
300
301
|
return results;
|
|
301
302
|
}
|
|
302
303
|
|
|
303
|
-
export default function (pi: ExtensionAPI) {
|
|
304
|
+
export default function opencodeRead(pi: ExtensionAPI) {
|
|
304
305
|
pi.registerTool({
|
|
305
306
|
name: "read",
|
|
306
307
|
label: "read",
|
|
@@ -317,14 +318,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
317
318
|
),
|
|
318
319
|
}),
|
|
319
320
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
320
|
-
const { filePath: rawPath, offset, limit } = params;
|
|
321
|
-
|
|
322
|
-
const absolutePath = isAbsolute(rawPath) ? rawPath : resolvePath(ctx.cwd, rawPath);
|
|
323
|
-
|
|
324
321
|
if (signal?.aborted) {
|
|
325
322
|
throw new Error("Operation aborted");
|
|
326
323
|
}
|
|
327
324
|
|
|
325
|
+
const { filePath: rawPath, offset, limit } = params;
|
|
326
|
+
|
|
327
|
+
const absolutePath = isAbsolute(rawPath) ? rawPath : resolvePath(ctx.cwd, rawPath);
|
|
328
|
+
|
|
328
329
|
// Check if path exists
|
|
329
330
|
let fileStat: Awaited<ReturnType<typeof stat>>;
|
|
330
331
|
try {
|
|
@@ -366,8 +367,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
366
367
|
}
|
|
367
368
|
|
|
368
369
|
// --- File read ---
|
|
369
|
-
let content: (TextContent | ImageContent)[];
|
|
370
|
-
let details: { truncation?: TruncationResult } | undefined;
|
|
371
370
|
|
|
372
371
|
// Check accessibility
|
|
373
372
|
try {
|
|
@@ -379,6 +378,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
379
378
|
};
|
|
380
379
|
}
|
|
381
380
|
|
|
381
|
+
let content: (TextContent | ImageContent)[];
|
|
382
|
+
|
|
382
383
|
// Check for images
|
|
383
384
|
const mimeType = await detectImageMimeTypeFromFile(absolutePath);
|
|
384
385
|
if (mimeType && SUPPORTED_IMAGE_MIMES.has(mimeType)) {
|
|
@@ -403,13 +404,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
403
404
|
};
|
|
404
405
|
}
|
|
405
406
|
|
|
406
|
-
const textContent = buffer.toString("
|
|
407
|
+
const textContent = buffer.toString("utf8");
|
|
407
408
|
const allLines = textContent.split("\n");
|
|
408
409
|
const totalFileLines = allLines.length;
|
|
409
410
|
|
|
410
411
|
// Apply offset
|
|
411
412
|
const startLine = offset ? Math.max(0, offset - 1) : 0;
|
|
412
|
-
const startLineDisplay = startLine + 1;
|
|
413
413
|
|
|
414
414
|
if (startLine >= allLines.length) {
|
|
415
415
|
return {
|
|
@@ -423,16 +423,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
423
423
|
};
|
|
424
424
|
}
|
|
425
425
|
|
|
426
|
+
const startLineDisplay = startLine + 1;
|
|
427
|
+
|
|
428
|
+
let details: { truncation?: TruncationResult } | undefined;
|
|
429
|
+
|
|
426
430
|
// Apply user-specified limit or default truncation
|
|
427
431
|
let selectedContent: string;
|
|
428
432
|
let userLimitedLines: number | undefined;
|
|
429
433
|
|
|
430
|
-
if (limit
|
|
434
|
+
if (limit === undefined) {
|
|
435
|
+
selectedContent = allLines.slice(startLine).join("\n");
|
|
436
|
+
} else {
|
|
431
437
|
const endLine = Math.min(startLine + limit, allLines.length);
|
|
432
438
|
selectedContent = allLines.slice(startLine, endLine).join("\n");
|
|
433
439
|
userLimitedLines = endLine - startLine;
|
|
434
|
-
} else {
|
|
435
|
-
selectedContent = allLines.slice(startLine).join("\n");
|
|
436
440
|
}
|
|
437
441
|
|
|
438
442
|
// Apply byte/line truncation
|
|
@@ -442,7 +446,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
442
446
|
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
|
443
447
|
|
|
444
448
|
if (truncation.firstLineExceedsLimit) {
|
|
445
|
-
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "
|
|
449
|
+
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf8"));
|
|
446
450
|
outputText = `<path>${absolutePath}</path>\n<type>file</type>\n`;
|
|
447
451
|
outputText += `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash to read this line.]`;
|
|
448
452
|
details = { truncation };
|
package/src/opencode-write.ts
CHANGED
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
|
|
17
17
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
18
18
|
import { dirname, resolve as resolvePath } from "node:path";
|
|
19
|
+
|
|
19
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
21
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
21
22
|
import { Type } from "typebox";
|
|
22
23
|
|
|
23
|
-
export default function (pi: ExtensionAPI) {
|
|
24
|
+
export default function opencodeWrite(pi: ExtensionAPI) {
|
|
24
25
|
pi.registerTool({
|
|
25
26
|
name: "write",
|
|
26
27
|
label: "write",
|
|
@@ -39,15 +40,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
39
40
|
const absolutePath = resolvePath(ctx.cwd, rawPath);
|
|
40
41
|
const dir = dirname(absolutePath);
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
};
|
|
43
|
+
const throwIfAborted = () => {
|
|
44
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
45
|
+
};
|
|
46
46
|
|
|
47
|
+
return withFileMutationQueue(absolutePath, async () => {
|
|
47
48
|
throwIfAborted();
|
|
48
49
|
await mkdir(dir, { recursive: true });
|
|
49
50
|
throwIfAborted();
|
|
50
|
-
await writeFile(absolutePath, content, "
|
|
51
|
+
await writeFile(absolutePath, content, "utf8");
|
|
51
52
|
throwIfAborted();
|
|
52
53
|
|
|
53
54
|
return {
|
package/src/todo-pendant.ts
CHANGED
|
@@ -65,12 +65,12 @@ function formatTaskLine(t: Task): string {
|
|
|
65
65
|
// Extension
|
|
66
66
|
// ---------------------------------------------------------------------------
|
|
67
67
|
|
|
68
|
-
export default function (pi: ExtensionAPI) {
|
|
68
|
+
export default function todoPendant(pi: ExtensionAPI) {
|
|
69
69
|
pi.on("tool_result", (event, ctx) => {
|
|
70
70
|
if (event.toolName !== "todo") return;
|
|
71
71
|
|
|
72
72
|
const details = event.details;
|
|
73
|
-
if (!isTaskDetails(details) ||
|
|
73
|
+
if (!isTaskDetails(details) || details.tasks.length === 0) {
|
|
74
74
|
ctx.ui.setWidget("todo-pendant", undefined);
|
|
75
75
|
return;
|
|
76
76
|
}
|
|
@@ -81,6 +81,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
81
81
|
return;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
ctx.ui.setWidget(
|
|
84
|
+
ctx.ui.setWidget(
|
|
85
|
+
"todo-pendant",
|
|
86
|
+
visible.map((t) => formatTaskLine(t)),
|
|
87
|
+
);
|
|
85
88
|
});
|
|
86
89
|
}
|
package/src/workspace-guard.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* File-modifying tools (write, edit) are gated:
|
|
5
5
|
* - Paths inside the workspace or /tmp are auto-allowed.
|
|
6
6
|
* - Paths outside require user approval via confirmation dialog.
|
|
7
|
-
* The dialog shows a
|
|
7
|
+
* The dialog shows a `diff` code block preview of the pending change.
|
|
8
8
|
*
|
|
9
9
|
* Read tools (read, ls, find, grep) are unrestricted.
|
|
10
10
|
*
|
|
@@ -12,14 +12,16 @@
|
|
|
12
12
|
* pi -e workspace-guard
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { basename, isAbsolute, join, resolve, relative, sep } from "node:path";
|
|
16
|
-
import { homedir } from "node:os";
|
|
17
15
|
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
18
|
+
|
|
18
19
|
import {
|
|
19
|
-
generateUnifiedPatch,
|
|
20
20
|
type ExtensionAPI,
|
|
21
|
+
generateUnifiedPatch,
|
|
21
22
|
type ToolCallEvent,
|
|
22
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
|
|
23
25
|
import { normalizeForEdit, replace } from "./opencode-edit-engine.js";
|
|
24
26
|
|
|
25
27
|
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
@@ -113,7 +115,7 @@ function applyChange(
|
|
|
113
115
|
for (const edit of input.edits) {
|
|
114
116
|
if (!isEditPair(edit)) return undefined;
|
|
115
117
|
if (!next.includes(edit.oldText)) return undefined;
|
|
116
|
-
next = next.replace(edit.oldText, edit.newText);
|
|
118
|
+
next = next.replace(edit.oldText, () => edit.newText);
|
|
117
119
|
}
|
|
118
120
|
return next;
|
|
119
121
|
}
|
|
@@ -121,7 +123,7 @@ function applyChange(
|
|
|
121
123
|
return undefined;
|
|
122
124
|
}
|
|
123
125
|
|
|
124
|
-
/** Wrap patch text in a
|
|
126
|
+
/** Wrap patch text in a `diff` code block, truncating very large diffs. */
|
|
125
127
|
function wrapDiff(patch: string): string {
|
|
126
128
|
const lines = patch.split("\n");
|
|
127
129
|
if (lines.length > MAX_PREVIEW_LINES) {
|
|
@@ -132,7 +134,7 @@ function wrapDiff(patch: string): string {
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
/**
|
|
135
|
-
* Build a
|
|
137
|
+
* Build a `diff` code block preview of the pending change.
|
|
136
138
|
* Returns undefined when the diff cannot be computed.
|
|
137
139
|
*/
|
|
138
140
|
export async function buildDiffPreview(
|
|
@@ -142,7 +144,7 @@ export async function buildDiffPreview(
|
|
|
142
144
|
): Promise<string | undefined> {
|
|
143
145
|
let oldContent = "";
|
|
144
146
|
try {
|
|
145
|
-
oldContent = await readFile(resolvedPath, "
|
|
147
|
+
oldContent = await readFile(resolvedPath, "utf8");
|
|
146
148
|
} catch {
|
|
147
149
|
// Unreadable or missing file: treat as empty so writes show as full additions.
|
|
148
150
|
}
|
|
@@ -171,7 +173,7 @@ export async function buildDiffPreview(
|
|
|
171
173
|
return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, newContent, 2));
|
|
172
174
|
}
|
|
173
175
|
|
|
174
|
-
export default function (pi: ExtensionAPI) {
|
|
176
|
+
export default function workspaceGuard(pi: ExtensionAPI) {
|
|
175
177
|
pi.on("before_agent_start", (event, ctx) => {
|
|
176
178
|
const currentCwd = ctx.cwd;
|
|
177
179
|
return {
|
|
@@ -214,7 +216,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
216
|
|
|
215
217
|
choice = await ctx.ui.select(title, ["Approve once", "Block", "Block with reason"]);
|
|
216
218
|
|
|
217
|
-
if (
|
|
219
|
+
if (choice === undefined) {
|
|
218
220
|
ctx.abort();
|
|
219
221
|
return { block: true, reason: "Write outside workspace cancelled by user." };
|
|
220
222
|
}
|