@trim21/personal-pi-extensions 0.0.124 → 0.0.126

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.126",
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
  }
@@ -5,10 +5,10 @@
5
5
  *
6
6
  * Tools:
7
7
  * - read-github-issue: Get issue details
8
- * - list-github-issues: List issues
8
+ * - list-github-issues: List or search issues
9
9
  * - read-github-issue-comments: Get issue comments
10
10
  * - read-github-pr: Get PR details
11
- * - list-github-prs: List PRs
11
+ * - list-github-prs: List or search PRs
12
12
  * - read-github-pr-diff: Get PR diff
13
13
  * - read-github-pr-status: Get PR status checks
14
14
  * - read-github-pr-comments: Get PR comments
@@ -20,8 +20,6 @@
20
20
  * - read-github-release: Get release details
21
21
  * - wait-github-pr-checks: Watch PR CI checks
22
22
  * - watch-github-run: Watch a workflow run
23
- * - search-github-issues: Search GitHub issues
24
- * - search-github-prs: Search GitHub pull requests
25
23
  *
26
24
  * Install:
27
25
  * cp gh-readonly.ts ~/.pi/agent/extensions/
@@ -33,6 +31,7 @@
33
31
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
34
32
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
35
33
  import { Type } from "typebox";
34
+ import { Value } from "typebox/value";
36
35
  import { spawn } from "node:child_process";
37
36
  import { homedir } from "node:os";
38
37
  import { mkdir, readFile, writeFile } from "node:fs/promises";
@@ -108,7 +107,7 @@ function runGh(
108
107
  resolve({ stdout, stderr, code: code ?? 0, killed, combined: combined.join("") });
109
108
  });
110
109
 
111
- proc.on("error", (_err) => {
110
+ proc.on("error", () => {
112
111
  if (timeoutId) clearTimeout(timeoutId);
113
112
  if (ctx.signal) {
114
113
  ctx.signal.removeEventListener("abort", killProcess);
@@ -158,6 +157,27 @@ function repoArgs(repo?: string): string[] {
158
157
  return repo ? ["--repo", repo] : [];
159
158
  }
160
159
 
160
+ // ── runtime validation schemas for JSON.parse results ───────────────────────
161
+
162
+ const repoViewSchema = Type.Object({ nameWithOwner: Type.String() });
163
+
164
+ const stepSchema = Type.Object({
165
+ name: Type.String(),
166
+ number: Type.Number(),
167
+ status: Type.String(),
168
+ conclusion: Type.Union([Type.String(), Type.Null()]),
169
+ });
170
+
171
+ const jobRunSchema = Type.Object({
172
+ id: Type.Number(),
173
+ name: Type.String(),
174
+ status: Type.String(),
175
+ conclusion: Type.Union([Type.String(), Type.Null()]),
176
+ steps: Type.Array(stepSchema),
177
+ });
178
+
179
+ const jobsResponseSchema = Type.Object({ jobs: Type.Array(jobRunSchema) });
180
+
161
181
  function truncate(
162
182
  text: string,
163
183
  maxLines = 2000,
@@ -192,6 +212,55 @@ function toToolResult(stdout: string): {
192
212
  return { content: [{ type: "text", text }], details: { truncated } };
193
213
  }
194
214
 
215
+ interface ListFilters {
216
+ repo?: string;
217
+ keywords?: string;
218
+ state?: string;
219
+ label?: string;
220
+ author?: string;
221
+ assignee?: string;
222
+ milestone?: string;
223
+ limit?: number;
224
+ }
225
+
226
+ /**
227
+ * List or search issues/PRs with structured filters.
228
+ *
229
+ * `gh issue list` / `gh pr list` are used when a repo is available (repo param or
230
+ * current directory), with keywords passed via `--search`. When no repo is given
231
+ * and keywords are present, falls back to `gh search issues` / `gh search prs`
232
+ * with plain keywords — never embedding a `repo:` qualifier in the query string,
233
+ * because `gh` mis-parses `repo:` values followed by spaces.
234
+ */
235
+ async function listGithub(
236
+ kind: "issue" | "pr",
237
+ params: ListFilters,
238
+ ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
239
+ ): Promise<string> {
240
+ const { repo, keywords, state, label, author, assignee, milestone, limit } = params;
241
+
242
+ if (!repo && keywords) {
243
+ const args = ["search", kind === "issue" ? "issues" : "prs", keywords];
244
+ if (state && state !== "all") args.push("--state", state);
245
+ if (label) args.push("--label", label);
246
+ if (author) args.push("--author", author);
247
+ if (assignee) args.push("--assignee", assignee);
248
+ if (milestone) args.push("--milestone", milestone);
249
+ if (limit) args.push("--limit", String(limit));
250
+ return ghExec(args, ctx);
251
+ }
252
+
253
+ const args = [kind, "list", ...repoArgs(repo)];
254
+ if (state) args.push("--state", state);
255
+ if (keywords) args.push("--search", keywords);
256
+ if (label) args.push("--label", label);
257
+ if (author) args.push("--author", author);
258
+ if (assignee) args.push("--assignee", assignee);
259
+ if (milestone) args.push("--milestone", milestone);
260
+ if (limit) args.push("--limit", String(limit));
261
+ return ghExec(args, ctx);
262
+ }
263
+
195
264
  // ── CI helpers ───────────────────────────────────────────────────────────────
196
265
 
197
266
  export interface StepInfo {
@@ -280,7 +349,8 @@ async function resolveRepo(
280
349
  ): Promise<string> {
281
350
  if (repo) return repo;
282
351
  const stdout = await ghExec(["repo", "view", "--json", "nameWithOwner"], { cwd, signal, input });
283
- return JSON.parse(stdout).nameWithOwner;
352
+ const { nameWithOwner } = Value.Parse(repoViewSchema, JSON.parse(stdout));
353
+ return nameWithOwner;
284
354
  }
285
355
 
286
356
  export function statusIcon(conclusion: string | null): string {
@@ -400,7 +470,7 @@ export default function (pi: ExtensionAPI) {
400
470
  repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
401
471
  }),
402
472
  async execute(_id, params, signal, _onUpdate, ctx) {
403
- const { number, repo } = params as { number: number | string; repo?: string };
473
+ const { number, repo } = params;
404
474
  return toToolResult(
405
475
  await ghExec(
406
476
  [
@@ -421,19 +491,23 @@ export default function (pi: ExtensionAPI) {
421
491
  pi.registerTool({
422
492
  name: "list-github-issues",
423
493
  label: "GitHub Issues List",
424
- description: "List GitHub issues with optional filters.",
425
- promptSnippet: "List GitHub issues",
494
+ description:
495
+ "List GitHub issues with optional filters and keyword search. When repo is omitted, searches across GitHub using keywords.",
496
+ promptSnippet: "List or search GitHub issues",
426
497
  parameters: Type.Object({
427
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
498
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
499
+ keywords: Type.Optional(Type.String({ description: "Search keywords (free text)" })),
428
500
  state: Type.Optional(Type.String({ description: "open, closed, all (default: open)" })),
501
+ label: Type.Optional(Type.String({ description: "Filter by label" })),
502
+ author: Type.Optional(Type.String({ description: "Filter by author" })),
503
+ assignee: Type.Optional(Type.String({ description: "Filter by assignee" })),
504
+ milestone: Type.Optional(Type.String({ description: "Filter by milestone" })),
429
505
  limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
430
506
  }),
431
507
  async execute(_id, params, signal, _onUpdate, ctx) {
432
- const { repo, state, limit } = params as { repo?: string; state?: string; limit?: number };
433
- const args = ["issue", "list", ...repoArgs(repo)];
434
- if (state) args.push("--state", state);
435
- if (limit) args.push("--limit", String(limit));
436
- return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
508
+ return toToolResult(
509
+ await listGithub("issue", params, { cwd: ctx.cwd, signal, input: params }),
510
+ );
437
511
  },
438
512
  });
439
513
 
@@ -448,7 +522,7 @@ export default function (pi: ExtensionAPI) {
448
522
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
449
523
  }),
450
524
  async execute(_id, params, signal, _onUpdate, ctx) {
451
- const { number, repo } = params as { number: number | string; repo?: string };
525
+ const { number, repo } = params;
452
526
  return toToolResult(
453
527
  await ghExec(
454
528
  [
@@ -469,21 +543,23 @@ export default function (pi: ExtensionAPI) {
469
543
  pi.registerTool({
470
544
  name: "list-github-prs",
471
545
  label: "GitHub PRs List",
472
- description: "List GitHub pull requests with optional filters.",
473
- promptSnippet: "List GitHub PRs",
546
+ description:
547
+ "List GitHub pull requests with optional filters and keyword search. When repo is omitted, searches across GitHub using keywords.",
548
+ promptSnippet: "List or search GitHub PRs",
474
549
  parameters: Type.Object({
475
- repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
550
+ repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
551
+ keywords: Type.Optional(Type.String({ description: "Search keywords (free text)" })),
476
552
  state: Type.Optional(
477
553
  Type.String({ description: "open, closed, merged, all (default: open)" }),
478
554
  ),
555
+ label: Type.Optional(Type.String({ description: "Filter by label" })),
556
+ author: Type.Optional(Type.String({ description: "Filter by author" })),
557
+ assignee: Type.Optional(Type.String({ description: "Filter by assignee" })),
558
+ milestone: Type.Optional(Type.String({ description: "Filter by milestone" })),
479
559
  limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
480
560
  }),
481
561
  async execute(_id, params, signal, _onUpdate, ctx) {
482
- const { repo, state, limit } = params as { repo?: string; state?: string; limit?: number };
483
- const args = ["pr", "list", ...repoArgs(repo)];
484
- if (state) args.push("--state", state);
485
- if (limit) args.push("--limit", String(limit));
486
- return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
562
+ return toToolResult(await listGithub("pr", params, { cwd: ctx.cwd, signal, input: params }));
487
563
  },
488
564
  });
489
565
 
@@ -498,10 +574,7 @@ export default function (pi: ExtensionAPI) {
498
574
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
499
575
  }),
500
576
  async execute(_id, params, signal, _onUpdate, ctx) {
501
- const { number, repo } = params as {
502
- number: number | string;
503
- repo?: string;
504
- };
577
+ const { number, repo } = params;
505
578
  const args = ["pr", "diff", String(number), ...repoArgs(repo)];
506
579
  return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
507
580
  },
@@ -518,7 +591,7 @@ export default function (pi: ExtensionAPI) {
518
591
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
519
592
  }),
520
593
  async execute(_id, params, signal, _onUpdate, ctx) {
521
- const { number, repo } = params as { number: number | string; repo?: string };
594
+ const { number, repo } = params;
522
595
  return toToolResult(
523
596
  await ghExec(["pr", "checks", String(number), ...repoArgs(repo)], {
524
597
  cwd: ctx.cwd,
@@ -547,11 +620,7 @@ export default function (pi: ExtensionAPI) {
547
620
  ),
548
621
  }),
549
622
  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
- };
623
+ const { number, repo, reviews } = params;
555
624
  let out: string;
556
625
  if (reviews) {
557
626
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
@@ -569,8 +638,8 @@ export default function (pi: ExtensionAPI) {
569
638
  }),
570
639
  ]);
571
640
 
572
- const reviewComments = JSON.parse(comments);
573
- const reviewSummaries = JSON.parse(reviewsOut);
641
+ const reviewComments = Value.Parse(Type.Array(Type.Unknown()), JSON.parse(comments));
642
+ const reviewSummaries = Value.Parse(Type.Array(Type.Unknown()), JSON.parse(reviewsOut));
574
643
 
575
644
  out = JSON.stringify(
576
645
  {
@@ -609,7 +678,7 @@ export default function (pi: ExtensionAPI) {
609
678
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
610
679
  }),
611
680
  async execute(_id, params, signal, _onUpdate, ctx) {
612
- const { number, repo } = params as { number: number | string; repo?: string };
681
+ const { number, repo } = params;
613
682
  return toToolResult(
614
683
  await ghExec(["issue", "view", String(number), ...repoArgs(repo), "--json", "comments"], {
615
684
  cwd: ctx.cwd,
@@ -635,12 +704,7 @@ export default function (pi: ExtensionAPI) {
635
704
  workflow: Type.Optional(Type.String({ description: "Filter by workflow name or file" })),
636
705
  }),
637
706
  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
- };
707
+ const { repo, limit, status, workflow } = params;
644
708
  const args = ["run", "list", ...repoArgs(repo)];
645
709
  if (limit) args.push("--limit", String(limit));
646
710
  if (status) args.push("--status", status);
@@ -685,14 +749,7 @@ export default function (pi: ExtensionAPI) {
685
749
  ),
686
750
  }),
687
751
  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
- };
752
+ const { run_id, repo, job, step, offset, limit } = params;
696
753
 
697
754
  // ── Fetch specific step logs ───────────────────────────────────────
698
755
  if (step !== undefined && step !== null) {
@@ -702,20 +759,7 @@ export default function (pi: ExtensionAPI) {
702
759
  ["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`],
703
760
  { cwd: ctx.cwd, signal, input: params },
704
761
  );
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
- };
762
+ const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
719
763
 
720
764
  if (!jobs || jobs.length === 0) {
721
765
  return {
@@ -889,20 +933,7 @@ export default function (pi: ExtensionAPI) {
889
933
  signal,
890
934
  input: params,
891
935
  });
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
- };
936
+ const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
906
937
 
907
938
  if (!jobs || jobs.length === 0) {
908
939
  return {
@@ -1034,7 +1065,7 @@ export default function (pi: ExtensionAPI) {
1034
1065
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1035
1066
  }),
1036
1067
  async execute(_id, params, signal, _onUpdate, ctx) {
1037
- const { run_id, repo } = params as { run_id: number | string; repo?: string };
1068
+ const { run_id, repo } = params;
1038
1069
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1039
1070
  return toToolResult(
1040
1071
  await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
@@ -1056,7 +1087,7 @@ export default function (pi: ExtensionAPI) {
1056
1087
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1057
1088
  }),
1058
1089
  async execute(_id, params, signal, _onUpdate, ctx) {
1059
- const { repo } = params as { repo?: string };
1090
+ const { repo } = params;
1060
1091
  const args = ["repo", "view"];
1061
1092
  if (repo) args.push(repo);
1062
1093
  return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
@@ -1074,7 +1105,7 @@ export default function (pi: ExtensionAPI) {
1074
1105
  limit: Type.Optional(Type.Number({ description: "Max results (default 10)" })),
1075
1106
  }),
1076
1107
  async execute(_id, params, signal, _onUpdate, ctx) {
1077
- const { repo, limit } = params as { repo?: string; limit?: number };
1108
+ const { repo, limit } = params;
1078
1109
  const args = ["release", "list", ...repoArgs(repo)];
1079
1110
  if (limit) args.push("--limit", String(limit));
1080
1111
  return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
@@ -1092,7 +1123,7 @@ export default function (pi: ExtensionAPI) {
1092
1123
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1093
1124
  }),
1094
1125
  async execute(_id, params, signal, _onUpdate, ctx) {
1095
- const { tag, repo } = params as { tag: string; repo?: string };
1126
+ const { tag, repo } = params;
1096
1127
  return toToolResult(
1097
1128
  await ghExec(["release", "view", tag, ...repoArgs(repo)], {
1098
1129
  cwd: ctx.cwd,
@@ -1119,11 +1150,7 @@ export default function (pi: ExtensionAPI) {
1119
1150
  ),
1120
1151
  }),
1121
1152
  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
- };
1153
+ const { number, repo, fail_fast } = params;
1127
1154
 
1128
1155
  onUpdate?.({
1129
1156
  content: [{ type: "text", text: `Watching CI checks for PR #${number}...` }],
@@ -1173,7 +1200,7 @@ export default function (pi: ExtensionAPI) {
1173
1200
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1174
1201
  }),
1175
1202
  async execute(_id, params, signal, onUpdate, ctx) {
1176
- const { run_id, repo } = params as { run_id: number | string; repo?: string };
1203
+ const { run_id, repo } = params;
1177
1204
 
1178
1205
  onUpdate?.({
1179
1206
  content: [{ type: "text", text: `Watching workflow run ${run_id}...` }],
@@ -1198,56 +1225,4 @@ export default function (pi: ExtensionAPI) {
1198
1225
  };
1199
1226
  },
1200
1227
  });
1201
-
1202
- // ── search-github-issues ───────────────────────────────────────────────────
1203
- pi.registerTool({
1204
- name: "search-github-issues",
1205
- label: "GitHub Issue Search",
1206
- description: "Search GitHub issues using GitHub search syntax.",
1207
- promptSnippet: "Search GitHub issues",
1208
- parameters: Type.Object({
1209
- query: Type.String({
1210
- description:
1211
- "GitHub search syntax (e.g. 'repo:owner/name keyword', 'is:open label:bug'). Do NOT include 'type:issue' or 'type:pr' qualifiers.",
1212
- }),
1213
- include_prs: Type.Optional(
1214
- Type.Boolean({
1215
- description: "Whether to include pull requests in results (default: false)",
1216
- }),
1217
- ),
1218
- limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
1219
- }),
1220
- 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
- };
1226
- const args = ["search", "issues", query];
1227
- if (include_prs) args.push("--include-prs");
1228
- if (limit) args.push("--limit", String(limit));
1229
- return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
1230
- },
1231
- });
1232
-
1233
- // ── search-github-prs ──────────────────────────────────────────────────────
1234
- pi.registerTool({
1235
- name: "search-github-prs",
1236
- label: "GitHub PR Search",
1237
- description: "Search GitHub pull requests using GitHub search syntax.",
1238
- promptSnippet: "Search GitHub PRs",
1239
- parameters: Type.Object({
1240
- query: Type.String({
1241
- description:
1242
- "GitHub search syntax (e.g. 'repo:owner/name keyword', 'is:open label:bug'). Do NOT include 'type:issue' or 'type:pr' qualifiers.",
1243
- }),
1244
- limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
1245
- }),
1246
- async execute(_id, params, signal, _onUpdate, ctx) {
1247
- const { query, limit } = params as { query: string; limit?: number };
1248
- const args = ["search", "prs", query];
1249
- if (limit) args.push("--limit", String(limit));
1250
- return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
1251
- },
1252
- });
1253
1228
  }
@@ -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);