@trim21/personal-pi-extensions 0.0.124 → 0.0.125

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.124",
3
+ "version": "0.0.125",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -19,14 +19,14 @@
19
19
  },
20
20
  "scripts": {
21
21
  "check": "tsc --noEmit && prettier --check .",
22
+ "lint": "eslint .",
22
23
  "format": "prettier --write .",
23
- "build:cli": "esbuild bin/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --external:@earendil-works/pi-coding-agent --external:typebox --external:node:*",
24
24
  "test": "vitest run",
25
25
  "prepare": "husky"
26
26
  },
27
27
  "peerDependencies": {
28
- "@earendil-works/pi-ai": "*",
29
28
  "@earendil-works/pi-agent-core": "*",
29
+ "@earendil-works/pi-ai": "*",
30
30
  "@earendil-works/pi-coding-agent": "*",
31
31
  "@earendil-works/pi-tui": "*",
32
32
  "typebox": "*"
@@ -36,11 +36,13 @@
36
36
  "@earendil-works/pi-coding-agent": "^0.80.0",
37
37
  "@types/node": "^24.0.0",
38
38
  "esbuild": "^0.28.1",
39
+ "eslint": "^10.8.0",
39
40
  "husky": "^9.1.7",
40
41
  "lint-staged": "^17.0.8",
41
42
  "prettier": "^3.6.0",
42
43
  "typebox": "1.3.1",
43
- "typescript": "^7.0.0",
44
+ "typescript": "^6.0.3",
45
+ "typescript-eslint": "^8.65.0",
44
46
  "vitest": "^4.1.10"
45
47
  },
46
48
  "pi": {
@@ -55,7 +57,11 @@
55
57
  ]
56
58
  },
57
59
  "lint-staged": {
58
- "*.{ts,tsx,js,jsx,json,md,yaml,yml}": [
60
+ "*.{ts,tsx,js,jsx}": [
61
+ "eslint --fix",
62
+ "prettier --write"
63
+ ],
64
+ "*.{json,md,yaml,yml}": [
59
65
  "prettier --write"
60
66
  ]
61
67
  },
@@ -62,7 +62,12 @@ import { fileURLToPath } from "node:url";
62
62
  import { Type } from "typebox";
63
63
  import { Value } from "typebox/value";
64
64
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
65
- import { type BashOperations, createBashTool, getAgentDir } from "@earendil-works/pi-coding-agent";
65
+ import {
66
+ type BashOperations,
67
+ createBashTool,
68
+ getAgentDir,
69
+ Theme,
70
+ } from "@earendil-works/pi-coding-agent";
66
71
 
67
72
  const SANDBOX_PROMPT = `
68
73
  ## Command Execution
@@ -166,10 +171,10 @@ function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
166
171
  const base = {
167
172
  mode: config.mode,
168
173
  bwrapPath: config.bwrapPath,
169
- writablePaths: config.writablePaths ?? ([".", "/tmp"] as string[]),
174
+ writablePaths: config.writablePaths ?? [".", "/tmp"],
170
175
  extraWritablePaths: config.extraWritablePaths,
171
- tmpfsPaths: config.tmpfsPaths ?? ([] as string[]),
172
- extraArgs: config.extraArgs ?? ([] as string[]),
176
+ tmpfsPaths: config.tmpfsPaths ?? [],
177
+ extraArgs: config.extraArgs ?? [],
173
178
  };
174
179
  switch (config.mode) {
175
180
  case "allow-all":
@@ -229,7 +234,7 @@ function loadConfig(cwd: string): BwrapConfig {
229
234
  try {
230
235
  Object.assign(target, JSON.parse(readFileSync(path, "utf-8")));
231
236
  } catch (e) {
232
- console.error(`Warning: Could not parse ${path}: ${e}`);
237
+ console.error(`Warning: Could not parse ${path}: ${String(e)}`);
233
238
  }
234
239
  }
235
240
  }
@@ -507,13 +512,6 @@ const sandboxedBashSchema = Type.Object({
507
512
  ),
508
513
  });
509
514
 
510
- interface SandboxedBashInput {
511
- command: string;
512
- timeout?: number;
513
- request_full_access?: boolean;
514
- request_full_access_reason?: string;
515
- }
516
-
517
515
  export default function (pi: ExtensionAPI) {
518
516
  pi.registerFlag("no-bwrap", {
519
517
  description: "Disable bwrap sandboxing for bash commands",
@@ -525,7 +523,6 @@ export default function (pi: ExtensionAPI) {
525
523
  const localBash = createBashTool(localCwd);
526
524
 
527
525
  let resolved: ResolvedBwrap | null = null;
528
- let manuallyDisabled = false;
529
526
 
530
527
  function getResolved(): ResolvedBwrap {
531
528
  if (!resolved) {
@@ -534,10 +531,6 @@ export default function (pi: ExtensionAPI) {
534
531
  return resolved;
535
532
  }
536
533
 
537
- function isEnabled() {
538
- return !manuallyDisabled && getResolved().bwrapEnabled;
539
- }
540
-
541
534
  function setMode(mode: BwrapMode) {
542
535
  const config = loadConfig(localCwd);
543
536
  config.mode = mode;
@@ -607,24 +600,21 @@ export default function (pi: ExtensionAPI) {
607
600
  },
608
601
  });
609
602
 
610
- pi.on("session_start", async (_event, ctx) => {
611
- const noBwrap = pi.getFlag("no-bwrap") as boolean;
603
+ pi.on("session_start", (_event, ctx) => {
604
+ const noBwrap = pi.getFlag("no-bwrap") === true;
612
605
 
613
606
  if (noBwrap) {
614
- manuallyDisabled = true;
615
607
  resolved = null;
616
608
  ctx.ui.notify("bwrap sandbox disabled via --no-bwrap", "warning");
617
609
  return;
618
610
  }
619
611
 
620
612
  if (!process.env.HOME) {
621
- manuallyDisabled = true;
622
613
  ctx.ui.notify("bwrap requires HOME environment variable", "error");
623
614
  return;
624
615
  }
625
616
 
626
617
  if (process.platform !== "linux") {
627
- manuallyDisabled = true;
628
618
  ctx.ui.notify("bwrap sandbox requires Linux", "warning");
629
619
  return;
630
620
  }
@@ -637,7 +627,6 @@ export default function (pi: ExtensionAPI) {
637
627
  findBwrap(resolved.bwrapPath);
638
628
  } catch (err) {
639
629
  resolved = null;
640
- manuallyDisabled = true;
641
630
  ctx.ui.notify(err instanceof Error ? err.message : "bwrap not found", "error");
642
631
  return;
643
632
  }
@@ -657,7 +646,6 @@ export default function (pi: ExtensionAPI) {
657
646
 
658
647
  pi.on("session_shutdown", () => {
659
648
  resolved = null;
660
- manuallyDisabled = false;
661
649
  });
662
650
 
663
651
  pi.on("before_agent_start", (event) => {
@@ -671,11 +659,11 @@ export default function (pi: ExtensionAPI) {
671
659
 
672
660
  pi.registerCommand("bwrap", {
673
661
  description: "Show bwrap sandbox configuration",
674
- handler: async (_args, ctx) => {
662
+ handler: (_args, ctx) => {
675
663
  const r = getResolved();
676
664
  if (!r.bwrapEnabled) {
677
665
  ctx.ui.notify(`bwrap disabled (mode: ${r.mode})`, "info");
678
- return;
666
+ return Promise.resolve();
679
667
  }
680
668
 
681
669
  const net = r.network ? "net" : "no-net";
@@ -686,6 +674,7 @@ export default function (pi: ExtensionAPI) {
686
674
  `bwrap ${r.mode} ${net} write:[${w.join(", ")}] tmpfs:[${t.join(", ") || "-"}]`,
687
675
  "info",
688
676
  );
677
+ return Promise.resolve();
689
678
  },
690
679
  });
691
680
 
@@ -694,7 +683,7 @@ export default function (pi: ExtensionAPI) {
694
683
  ctx: {
695
684
  ui: {
696
685
  notify: (m: string, t?: "info" | "warning" | "error") => void;
697
- theme: any;
686
+ theme: Theme;
698
687
  setStatus: (k: string, t: string | undefined) => void;
699
688
  };
700
689
  },
@@ -718,16 +707,16 @@ export default function (pi: ExtensionAPI) {
718
707
 
719
708
  pi.registerCommand("bwrap-allow-all", {
720
709
  description: "Disable bwrap sandbox, full access",
721
- handler: async (_args, ctx) => switchMode("allow-all", ctx),
710
+ handler: (_args, ctx) => Promise.resolve(switchMode("allow-all", ctx)),
722
711
  });
723
712
 
724
713
  pi.registerCommand("bwrap-workspace-write", {
725
714
  description: "Sandbox on, network off, workspace writable",
726
- handler: async (_args, ctx) => switchMode("workspace-write", ctx),
715
+ handler: (_args, ctx) => Promise.resolve(switchMode("workspace-write", ctx)),
727
716
  });
728
717
 
729
718
  pi.registerCommand("bwrap-readonly", {
730
719
  description: "Sandbox on, network off, no writes",
731
- handler: async (_args, ctx) => switchMode("readonly", ctx),
720
+ handler: (_args, ctx) => Promise.resolve(switchMode("readonly", ctx)),
732
721
  });
733
722
  }
@@ -33,6 +33,7 @@
33
33
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
34
34
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
35
35
  import { Type } from "typebox";
36
+ import { Value } from "typebox/value";
36
37
  import { spawn } from "node:child_process";
37
38
  import { homedir } from "node:os";
38
39
  import { mkdir, readFile, writeFile } from "node:fs/promises";
@@ -108,7 +109,7 @@ function runGh(
108
109
  resolve({ stdout, stderr, code: code ?? 0, killed, combined: combined.join("") });
109
110
  });
110
111
 
111
- proc.on("error", (_err) => {
112
+ proc.on("error", () => {
112
113
  if (timeoutId) clearTimeout(timeoutId);
113
114
  if (ctx.signal) {
114
115
  ctx.signal.removeEventListener("abort", killProcess);
@@ -158,6 +159,27 @@ function repoArgs(repo?: string): string[] {
158
159
  return repo ? ["--repo", repo] : [];
159
160
  }
160
161
 
162
+ // ── runtime validation schemas for JSON.parse results ───────────────────────
163
+
164
+ const repoViewSchema = Type.Object({ nameWithOwner: Type.String() });
165
+
166
+ const stepSchema = Type.Object({
167
+ name: Type.String(),
168
+ number: Type.Number(),
169
+ status: Type.String(),
170
+ conclusion: Type.Union([Type.String(), Type.Null()]),
171
+ });
172
+
173
+ const jobRunSchema = Type.Object({
174
+ id: Type.Number(),
175
+ name: Type.String(),
176
+ status: Type.String(),
177
+ conclusion: Type.Union([Type.String(), Type.Null()]),
178
+ steps: Type.Array(stepSchema),
179
+ });
180
+
181
+ const jobsResponseSchema = Type.Object({ jobs: Type.Array(jobRunSchema) });
182
+
161
183
  function truncate(
162
184
  text: string,
163
185
  maxLines = 2000,
@@ -280,7 +302,8 @@ async function resolveRepo(
280
302
  ): Promise<string> {
281
303
  if (repo) return repo;
282
304
  const stdout = await ghExec(["repo", "view", "--json", "nameWithOwner"], { cwd, signal, input });
283
- return JSON.parse(stdout).nameWithOwner;
305
+ const { nameWithOwner } = Value.Parse(repoViewSchema, JSON.parse(stdout));
306
+ return nameWithOwner;
284
307
  }
285
308
 
286
309
  export function statusIcon(conclusion: string | null): string {
@@ -400,7 +423,7 @@ export default function (pi: ExtensionAPI) {
400
423
  repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
401
424
  }),
402
425
  async execute(_id, params, signal, _onUpdate, ctx) {
403
- const { number, repo } = params as { number: number | string; repo?: string };
426
+ const { number, repo } = params;
404
427
  return toToolResult(
405
428
  await ghExec(
406
429
  [
@@ -429,7 +452,7 @@ export default function (pi: ExtensionAPI) {
429
452
  limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
430
453
  }),
431
454
  async execute(_id, params, signal, _onUpdate, ctx) {
432
- const { repo, state, limit } = params as { repo?: string; state?: string; limit?: number };
455
+ const { repo, state, limit } = params;
433
456
  const args = ["issue", "list", ...repoArgs(repo)];
434
457
  if (state) args.push("--state", state);
435
458
  if (limit) args.push("--limit", String(limit));
@@ -448,7 +471,7 @@ export default function (pi: ExtensionAPI) {
448
471
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
449
472
  }),
450
473
  async execute(_id, params, signal, _onUpdate, ctx) {
451
- const { number, repo } = params as { number: number | string; repo?: string };
474
+ const { number, repo } = params;
452
475
  return toToolResult(
453
476
  await ghExec(
454
477
  [
@@ -479,7 +502,7 @@ export default function (pi: ExtensionAPI) {
479
502
  limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
480
503
  }),
481
504
  async execute(_id, params, signal, _onUpdate, ctx) {
482
- const { repo, state, limit } = params as { repo?: string; state?: string; limit?: number };
505
+ const { repo, state, limit } = params;
483
506
  const args = ["pr", "list", ...repoArgs(repo)];
484
507
  if (state) args.push("--state", state);
485
508
  if (limit) args.push("--limit", String(limit));
@@ -498,10 +521,7 @@ export default function (pi: ExtensionAPI) {
498
521
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
499
522
  }),
500
523
  async execute(_id, params, signal, _onUpdate, ctx) {
501
- const { number, repo } = params as {
502
- number: number | string;
503
- repo?: string;
504
- };
524
+ const { number, repo } = params;
505
525
  const args = ["pr", "diff", String(number), ...repoArgs(repo)];
506
526
  return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
507
527
  },
@@ -518,7 +538,7 @@ export default function (pi: ExtensionAPI) {
518
538
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
519
539
  }),
520
540
  async execute(_id, params, signal, _onUpdate, ctx) {
521
- const { number, repo } = params as { number: number | string; repo?: string };
541
+ const { number, repo } = params;
522
542
  return toToolResult(
523
543
  await ghExec(["pr", "checks", String(number), ...repoArgs(repo)], {
524
544
  cwd: ctx.cwd,
@@ -547,11 +567,7 @@ export default function (pi: ExtensionAPI) {
547
567
  ),
548
568
  }),
549
569
  async execute(_id, params, signal, _onUpdate, ctx) {
550
- const { number, repo, reviews } = params as {
551
- number: number | string;
552
- repo?: string;
553
- reviews?: boolean;
554
- };
570
+ const { number, repo, reviews } = params;
555
571
  let out: string;
556
572
  if (reviews) {
557
573
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
@@ -569,8 +585,8 @@ export default function (pi: ExtensionAPI) {
569
585
  }),
570
586
  ]);
571
587
 
572
- const reviewComments = JSON.parse(comments);
573
- const reviewSummaries = JSON.parse(reviewsOut);
588
+ const reviewComments = Value.Parse(Type.Array(Type.Unknown()), JSON.parse(comments));
589
+ const reviewSummaries = Value.Parse(Type.Array(Type.Unknown()), JSON.parse(reviewsOut));
574
590
 
575
591
  out = JSON.stringify(
576
592
  {
@@ -609,7 +625,7 @@ export default function (pi: ExtensionAPI) {
609
625
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
610
626
  }),
611
627
  async execute(_id, params, signal, _onUpdate, ctx) {
612
- const { number, repo } = params as { number: number | string; repo?: string };
628
+ const { number, repo } = params;
613
629
  return toToolResult(
614
630
  await ghExec(["issue", "view", String(number), ...repoArgs(repo), "--json", "comments"], {
615
631
  cwd: ctx.cwd,
@@ -635,12 +651,7 @@ export default function (pi: ExtensionAPI) {
635
651
  workflow: Type.Optional(Type.String({ description: "Filter by workflow name or file" })),
636
652
  }),
637
653
  async execute(_id, params, signal, _onUpdate, ctx) {
638
- const { repo, limit, status, workflow } = params as {
639
- repo?: string;
640
- limit?: number;
641
- status?: string;
642
- workflow?: string;
643
- };
654
+ const { repo, limit, status, workflow } = params;
644
655
  const args = ["run", "list", ...repoArgs(repo)];
645
656
  if (limit) args.push("--limit", String(limit));
646
657
  if (status) args.push("--status", status);
@@ -685,14 +696,7 @@ export default function (pi: ExtensionAPI) {
685
696
  ),
686
697
  }),
687
698
  async execute(_id, params, signal, onUpdate, ctx) {
688
- const { run_id, repo, job, step, offset, limit } = params as {
689
- run_id: number | string;
690
- repo?: string;
691
- job?: string;
692
- step?: string;
693
- offset?: number;
694
- limit?: number;
695
- };
699
+ const { run_id, repo, job, step, offset, limit } = params;
696
700
 
697
701
  // ── Fetch specific step logs ───────────────────────────────────────
698
702
  if (step !== undefined && step !== null) {
@@ -702,20 +706,7 @@ export default function (pi: ExtensionAPI) {
702
706
  ["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`],
703
707
  { cwd: ctx.cwd, signal, input: params },
704
708
  );
705
- const { jobs } = JSON.parse(jobsOut) as {
706
- jobs: Array<{
707
- id: number;
708
- name: string;
709
- status: string;
710
- conclusion: string | null;
711
- steps: Array<{
712
- name: string;
713
- number: number;
714
- status: string;
715
- conclusion: string | null;
716
- }>;
717
- }>;
718
- };
709
+ const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
719
710
 
720
711
  if (!jobs || jobs.length === 0) {
721
712
  return {
@@ -889,20 +880,7 @@ export default function (pi: ExtensionAPI) {
889
880
  signal,
890
881
  input: params,
891
882
  });
892
- const { jobs } = JSON.parse(jobsOut) as {
893
- jobs: Array<{
894
- id: number;
895
- name: string;
896
- status: string;
897
- conclusion: string | null;
898
- steps: Array<{
899
- name: string;
900
- number: number;
901
- status: string;
902
- conclusion: string | null;
903
- }>;
904
- }>;
905
- };
883
+ const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
906
884
 
907
885
  if (!jobs || jobs.length === 0) {
908
886
  return {
@@ -1034,7 +1012,7 @@ export default function (pi: ExtensionAPI) {
1034
1012
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1035
1013
  }),
1036
1014
  async execute(_id, params, signal, _onUpdate, ctx) {
1037
- const { run_id, repo } = params as { run_id: number | string; repo?: string };
1015
+ const { run_id, repo } = params;
1038
1016
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1039
1017
  return toToolResult(
1040
1018
  await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
@@ -1056,7 +1034,7 @@ export default function (pi: ExtensionAPI) {
1056
1034
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1057
1035
  }),
1058
1036
  async execute(_id, params, signal, _onUpdate, ctx) {
1059
- const { repo } = params as { repo?: string };
1037
+ const { repo } = params;
1060
1038
  const args = ["repo", "view"];
1061
1039
  if (repo) args.push(repo);
1062
1040
  return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
@@ -1074,7 +1052,7 @@ export default function (pi: ExtensionAPI) {
1074
1052
  limit: Type.Optional(Type.Number({ description: "Max results (default 10)" })),
1075
1053
  }),
1076
1054
  async execute(_id, params, signal, _onUpdate, ctx) {
1077
- const { repo, limit } = params as { repo?: string; limit?: number };
1055
+ const { repo, limit } = params;
1078
1056
  const args = ["release", "list", ...repoArgs(repo)];
1079
1057
  if (limit) args.push("--limit", String(limit));
1080
1058
  return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
@@ -1092,7 +1070,7 @@ export default function (pi: ExtensionAPI) {
1092
1070
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1093
1071
  }),
1094
1072
  async execute(_id, params, signal, _onUpdate, ctx) {
1095
- const { tag, repo } = params as { tag: string; repo?: string };
1073
+ const { tag, repo } = params;
1096
1074
  return toToolResult(
1097
1075
  await ghExec(["release", "view", tag, ...repoArgs(repo)], {
1098
1076
  cwd: ctx.cwd,
@@ -1119,11 +1097,7 @@ export default function (pi: ExtensionAPI) {
1119
1097
  ),
1120
1098
  }),
1121
1099
  async execute(_id, params, signal, onUpdate, ctx) {
1122
- const { number, repo, fail_fast } = params as {
1123
- number: number | string;
1124
- repo?: string;
1125
- fail_fast?: boolean;
1126
- };
1100
+ const { number, repo, fail_fast } = params;
1127
1101
 
1128
1102
  onUpdate?.({
1129
1103
  content: [{ type: "text", text: `Watching CI checks for PR #${number}...` }],
@@ -1173,7 +1147,7 @@ export default function (pi: ExtensionAPI) {
1173
1147
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1174
1148
  }),
1175
1149
  async execute(_id, params, signal, onUpdate, ctx) {
1176
- const { run_id, repo } = params as { run_id: number | string; repo?: string };
1150
+ const { run_id, repo } = params;
1177
1151
 
1178
1152
  onUpdate?.({
1179
1153
  content: [{ type: "text", text: `Watching workflow run ${run_id}...` }],
@@ -1218,11 +1192,7 @@ export default function (pi: ExtensionAPI) {
1218
1192
  limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
1219
1193
  }),
1220
1194
  async execute(_id, params, signal, _onUpdate, ctx) {
1221
- const { query, include_prs, limit } = params as {
1222
- query: string;
1223
- include_prs?: boolean;
1224
- limit?: number;
1225
- };
1195
+ const { query, include_prs, limit } = params;
1226
1196
  const args = ["search", "issues", query];
1227
1197
  if (include_prs) args.push("--include-prs");
1228
1198
  if (limit) args.push("--limit", String(limit));
@@ -1244,7 +1214,7 @@ export default function (pi: ExtensionAPI) {
1244
1214
  limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
1245
1215
  }),
1246
1216
  async execute(_id, params, signal, _onUpdate, ctx) {
1247
- const { query, limit } = params as { query: string; limit?: number };
1217
+ const { query, limit } = params;
1248
1218
  const args = ["search", "prs", query];
1249
1219
  if (limit) args.push("--limit", String(limit));
1250
1220
  return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
@@ -517,7 +517,9 @@ export default function (pi: ExtensionAPI) {
517
517
  } catch (error: unknown) {
518
518
  throwIfAborted();
519
519
  const msg =
520
- error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
520
+ error instanceof Error && "code" in error && typeof error.code === "string"
521
+ ? `Error code: ${error.code}`
522
+ : String(error);
521
523
  throw new Error(`Could not edit file: ${filePath}. ${msg}.`);
522
524
  }
523
525
  throwIfAborted();
@@ -317,11 +317,7 @@ export default function (pi: ExtensionAPI) {
317
317
  ),
318
318
  }),
319
319
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
320
- const {
321
- filePath: rawPath,
322
- offset,
323
- limit,
324
- } = params as { filePath: string; offset?: number; limit?: number };
320
+ const { filePath: rawPath, offset, limit } = params;
325
321
 
326
322
  const absolutePath = isAbsolute(rawPath) ? rawPath : resolvePath(ctx.cwd, rawPath);
327
323
 
@@ -336,9 +332,7 @@ export default function (pi: ExtensionAPI) {
336
332
  } catch {
337
333
  const suggestion = await didYouMean(absolutePath);
338
334
  return {
339
- content: [
340
- { type: "text", text: `File not found: ${absolutePath}${suggestion}` },
341
- ] as TextContent[],
335
+ content: [{ type: "text", text: `File not found: ${absolutePath}${suggestion}` }],
342
336
  details: undefined,
343
337
  };
344
338
  }
@@ -366,7 +360,7 @@ export default function (pi: ExtensionAPI) {
366
360
  output += `\n</entries>`;
367
361
 
368
362
  return {
369
- content: [{ type: "text", text: output }] as TextContent[],
363
+ content: [{ type: "text", text: output }],
370
364
  details: undefined,
371
365
  };
372
366
  }
@@ -380,7 +374,7 @@ export default function (pi: ExtensionAPI) {
380
374
  await access(absolutePath, constants.R_OK);
381
375
  } catch {
382
376
  return {
383
- content: [{ type: "text", text: `File not readable: ${absolutePath}` }] as TextContent[],
377
+ content: [{ type: "text", text: `File not readable: ${absolutePath}` }],
384
378
  details: undefined,
385
379
  };
386
380
  }
@@ -392,7 +386,7 @@ export default function (pi: ExtensionAPI) {
392
386
  const base64 = buffer.toString("base64");
393
387
  content = [
394
388
  { type: "text", text: `[Image: ${mimeType}, ${formatSize(buffer.length)}]` },
395
- { type: "image", data: base64, mimeType } as ImageContent,
389
+ { type: "image", data: base64, mimeType },
396
390
  ];
397
391
  return { content, details: undefined };
398
392
  }
@@ -404,9 +398,7 @@ export default function (pi: ExtensionAPI) {
404
398
  // Binary file detection
405
399
  if (isBinaryExtension(absolutePath) || isBinaryFileBySample(sample)) {
406
400
  return {
407
- content: [
408
- { type: "text", text: `Cannot read binary file: ${absolutePath}` },
409
- ] as TextContent[],
401
+ content: [{ type: "text", text: `Cannot read binary file: ${absolutePath}` }],
410
402
  details: undefined,
411
403
  };
412
404
  }
@@ -426,7 +418,7 @@ export default function (pi: ExtensionAPI) {
426
418
  type: "text",
427
419
  text: `Offset ${offset} is beyond end of file (${allLines.length} lines total)`,
428
420
  },
429
- ] as TextContent[],
421
+ ],
430
422
  details: undefined,
431
423
  };
432
424
  }
@@ -477,7 +469,7 @@ export default function (pi: ExtensionAPI) {
477
469
  }
478
470
  }
479
471
 
480
- content = [{ type: "text", text: outputText }] as TextContent[];
472
+ content = [{ type: "text", text: outputText }];
481
473
 
482
474
  return { content, details };
483
475
  },
@@ -35,7 +35,7 @@ export default function (pi: ExtensionAPI) {
35
35
  content: Type.String({ description: "The content to write to the file" }),
36
36
  }),
37
37
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
38
- const { filePath: rawPath, content } = params as { filePath: string; content: string };
38
+ const { filePath: rawPath, content } = params;
39
39
  const absolutePath = resolvePath(ctx.cwd, rawPath);
40
40
  const dir = dirname(absolutePath);
41
41
 
@@ -51,10 +51,7 @@ export default function (pi: ExtensionAPI) {
51
51
  throwIfAborted();
52
52
 
53
53
  return {
54
- content: [{ type: "text", text: `Wrote file successfully: ${absolutePath}` }] as Array<{
55
- type: "text";
56
- text: string;
57
- }>,
54
+ content: [{ type: "text", text: `Wrote file successfully: ${absolutePath}` }],
58
55
  details: undefined,
59
56
  };
60
57
  });
@@ -36,6 +36,10 @@ interface TaskDetails {
36
36
  error?: string;
37
37
  }
38
38
 
39
+ function isTaskDetails(d: unknown): d is TaskDetails {
40
+ return typeof d === "object" && d !== null && "tasks" in d && Array.isArray(d.tasks);
41
+ }
42
+
39
43
  // ---------------------------------------------------------------------------
40
44
  // Helpers
41
45
  // ---------------------------------------------------------------------------
@@ -65,8 +69,8 @@ export default function (pi: ExtensionAPI) {
65
69
  pi.on("tool_result", (event, ctx) => {
66
70
  if (event.toolName !== "todo") return;
67
71
 
68
- const details = event.details as TaskDetails | undefined;
69
- if (!details?.tasks?.length) {
72
+ const details = event.details;
73
+ if (!isTaskDetails(details) || !details.tasks.length) {
70
74
  ctx.ui.setWidget("todo-pendant", undefined);
71
75
  return;
72
76
  }
@@ -13,11 +13,18 @@
13
13
 
14
14
  import { isAbsolute, join, resolve, relative, sep } from "node:path";
15
15
  import { homedir } from "node:os";
16
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import type { ExtensionAPI, ToolCallEvent } from "@earendil-works/pi-coding-agent";
17
17
 
18
18
  const WRITE_TOOLS = new Set(["write", "edit"]);
19
19
  const ALWAYS_ALLOW = ["/tmp"];
20
20
 
21
+ function getWriteTarget(input: ToolCallEvent["input"]): string | undefined {
22
+ if (typeof input !== "object" || input === null) return undefined;
23
+ if ("path" in input && typeof input.path === "string") return input.path;
24
+ if ("filePath" in input && typeof input.filePath === "string") return input.filePath;
25
+ return undefined;
26
+ }
27
+
21
28
  function resolvePath(filePath: string, cwd: string): string {
22
29
  let p = filePath;
23
30
  if (p.startsWith("~")) {
@@ -46,7 +53,7 @@ function isPathAllowed(resolvedPath: string, cwd: string): boolean {
46
53
  }
47
54
 
48
55
  export default function (pi: ExtensionAPI) {
49
- pi.on("before_agent_start", async (event, ctx) => {
56
+ pi.on("before_agent_start", (event, ctx) => {
50
57
  const currentCwd = ctx.cwd;
51
58
  return {
52
59
  systemPrompt:
@@ -60,8 +67,7 @@ export default function (pi: ExtensionAPI) {
60
67
  pi.on("tool_call", async (event, ctx) => {
61
68
  if (!WRITE_TOOLS.has(event.toolName)) return;
62
69
 
63
- const input = event.input as { path?: string; filePath?: string };
64
- const rawPath = input.path ?? input.filePath;
70
+ const rawPath = getWriteTarget(event.input);
65
71
  if (!rawPath) return;
66
72
 
67
73
  const resolved = resolvePath(rawPath, ctx.cwd);