@trim21/personal-pi-extensions 0.0.146 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.146",
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,9 +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",
38
- "esbuild": "^0.28.1",
39
+ "@typescript-eslint/utils": "8.66.0",
39
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",
40
48
  "husky": "^9.1.7",
41
49
  "lint-staged": "^17.0.8",
42
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 { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
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;
@@ -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 { existsSync, readFileSync, openSync, closeSync } from "node:fs";
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
- let bwrapPath = "";
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
- case "workspace-write":
169
+ }
170
+ case "workspace-write": {
183
171
  return { ...base, bwrapEnabled: true, network: false };
184
- case "readonly":
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, "utf-8")));
236
- } catch (e) {
237
- console.error(`Warning: Could not parse ${path}: ${String(e)}`);
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 = !resolved.network ? getSeccompFd() : undefined;
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 !== undefined
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: Array<() => void> = [];
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
- .replace(/&/g, "&amp;")
449
- .replace(/</g, "&lt;")
450
- .replace(/>/g, "&gt;")
451
- .replace(/"/g, "&quot;");
440
+ .replaceAll("&", "&amp;")
441
+ .replaceAll("<", "&lt;")
442
+ .replaceAll(">", "&gt;")
443
+ .replaceAll('"', "&quot;");
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 (```) for literal plain-text rendering.
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 (typeof choice === "undefined") {
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 (err) {
620
+ } catch (error) {
629
621
  resolved = null;
630
- ctx.ui.notify(err instanceof Error ? err.message : "bwrap not found", "error");
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
- if (!r.bwrapEnabled) {
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({
@@ -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 (!killed) {
74
- killed = true;
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, "utf-8") <= maxBytes) {
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", "utf-8");
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: Array<{ type: "text"; text: string }>;
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 ? { input } : {}), truncated },
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
- ): Array<{ number: number; name: string; conclusion: string | null; expanded?: boolean }> {
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) ? { expanded: true } : {}),
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, "utf-8");
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
- case "failure":
408
+ }
409
+ case "failure": {
406
410
  return "❌";
407
- case "cancelled":
411
+ }
412
+ case "cancelled": {
408
413
  return "🚫";
409
- case "skipped":
414
+ }
415
+ case "skipped": {
410
416
  return "⏭️";
411
- case "timed_out":
417
+ }
418
+ case "timed_out": {
412
419
  return "⏰";
413
- case "action_required":
420
+ }
421
+ case "action_required": {
414
422
  return "⚠️";
415
- default:
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: Array<{ number: number; name: string }>,
456
+ apiSteps: { number: number; name: string }[],
447
457
  ): string | null {
448
- const targetStep = apiSteps.find((s) => s.number === stepNumber);
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: Array<{ line: number; action: string }> = [];
463
+ const groups: { line: number; action: string }[] = [];
455
464
  let depth = 0;
456
- for (let i = 0; i < lines.length; i++) {
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 = line.match(/##\[group\](.*)/);
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
- .sort((a, b) => a.number - b.number);
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.entries()]
509
+ const anchors = [...stepToGroup]
502
510
  .map(([stepNum, gi]) => ({ stepNum, line: groups[gi].line }))
503
- .sort((a, b) => a.line - b.line);
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 (let gi = 0; gi < groups.length; gi++) {
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 type CiLogsResult = {
547
- content: Array<{ type: "text"; text: string }>;
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
- const ANSI_RE = /\u001b\[[0-9;]*m/g;
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
- .replace(ANSI_RE, "")
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) === job : j.name === job));
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) === job : j.name === job));
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 ? { output: 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[searchLines.length - 1] === "") {
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 (let j = 0; j < searchLines.length; j++) {
78
+ for (const [j, searchLine] of searchLines.entries()) {
79
79
  const originalTrimmed = originalLines[i + j].trim();
80
- const searchTrimmed = searchLines[j].trim();
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.substring(matchStartIndex, matchEndIndex);
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[searchLines.length - 1] === "") {
109
+ if (searchLines.at(-1) === "") {
110
110
  searchLines.pop();
111
111
  }
112
112
  const firstLineSearch = searchLines[0].trim();
113
- const lastLineSearch = searchLines[searchLines.length - 1].trim();
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: Array<{ startLine: number; endLine: number }> = [];
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.0;
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.substring(matchStartIndex, matchEndIndex);
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.0;
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.substring(matchStartIndex, matchEndIndex);
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 (let i = 0; i < lines.length; i++) {
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.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
234
- .join("\\s+");
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
- const EscapeNormalizedReplacer: Replacer = function* (content, find) {
286
- const unescapeString = (str: string): string => {
287
- return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (_match, capturedChar) => {
288
- switch (capturedChar) {
289
- case "n":
290
- return "\n";
291
- case "t":
292
- return "\t";
293
- case "r":
294
- return "\r";
295
- case "'":
296
- return "'";
297
- case '"':
298
- return '"';
299
- case "`":
300
- return "`";
301
- case "\\":
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[findLines.length - 1] === "") {
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[findLines.length - 1].trim();
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 content.substring(0, index) + newString + content.substring(index + search.length);
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
 
@@ -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("utf-8");
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, "utf-8");
107
+ await writeFile(absolutePath, finalContent, "utf8");
105
108
  throwIfAborted();
106
109
 
107
110
  const diffResult = generateDiffString(normalizedContent, newContent);
@@ -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: Array<{
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.charCodeAt(i)) return false;
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 < 0) return false;
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, "utf-8");
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], "utf-8") : 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], "utf-8") + (i > 0 ? 1 : 0);
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, "utf-8"),
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("utf-8");
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 !== undefined) {
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], "utf-8"));
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 };
@@ -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
- return withFileMutationQueue(absolutePath, async () => {
43
- const throwIfAborted = () => {
44
- if (signal?.aborted) throw new Error("Operation aborted");
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, "utf-8");
51
+ await writeFile(absolutePath, content, "utf8");
51
52
  throwIfAborted();
52
53
 
53
54
  return {
@@ -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) || !details.tasks.length) {
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("todo-pendant", [...visible.map(formatTaskLine)]);
84
+ ctx.ui.setWidget(
85
+ "todo-pendant",
86
+ visible.map((t) => formatTaskLine(t)),
87
+ );
85
88
  });
86
89
  }
@@ -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 ```diff code block preview of the pending change.
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 ```diff code block, truncating very large diffs. */
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 ```diff code block preview of the pending change.
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, "utf-8");
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 (typeof choice === "undefined") {
219
+ if (choice === undefined) {
218
220
  ctx.abort();
219
221
  return { block: true, reason: "Write outside workspace cancelled by user." };
220
222
  }