@trim21/personal-pi-extensions 0.0.123 → 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.123",
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";
@@ -48,8 +49,7 @@ interface GhResult {
48
49
 
49
50
  // ── helpers ──────────────────────────────────────────────────────────────────
50
51
 
51
- function execGh(
52
- pi: ExtensionAPI,
52
+ function runGh(
53
53
  args: string[],
54
54
  ctx: { cwd?: string; signal?: AbortSignal; timeout?: number },
55
55
  ): Promise<GhResult> {
@@ -109,7 +109,7 @@ function execGh(
109
109
  resolve({ stdout, stderr, code: code ?? 0, killed, combined: combined.join("") });
110
110
  });
111
111
 
112
- proc.on("error", (_err) => {
112
+ proc.on("error", () => {
113
113
  if (timeoutId) clearTimeout(timeoutId);
114
114
  if (ctx.signal) {
115
115
  ctx.signal.removeEventListener("abort", killProcess);
@@ -119,35 +119,67 @@ function execGh(
119
119
  });
120
120
  }
121
121
 
122
- /** Result of a gh invocation. On failure, `args` carries the full raw command input for debugging. */
123
- export interface GhExecResult {
124
- ok: boolean;
125
- stdout: string;
126
- error: string | null;
127
- args: string[];
122
+ /**
123
+ * Error thrown by `ghExec` when the `gh` invocation exits non-zero.
124
+ * The message carries the toolcall input (JSON) wrapped in `<input>` markers,
125
+ * and the command output wrapped in `<output>` markers.
126
+ */
127
+ export class GhError extends Error {
128
+ readonly args: string[];
129
+ readonly code: number;
130
+ readonly stdout: string;
131
+ readonly stderr: string;
132
+ readonly input?: unknown;
133
+
134
+ constructor(args: string[], result: GhResult, input?: unknown) {
135
+ const inputText = input === undefined ? "" : `<input>${JSON.stringify(input)}<input>\n`;
136
+ super(`${inputText}<output>${result.combined.trim() || `exit code ${result.code}`}<output>`);
137
+ this.name = "GhError";
138
+ this.args = args;
139
+ this.code = result.code;
140
+ this.stdout = result.stdout;
141
+ this.stderr = result.stderr;
142
+ this.input = input;
143
+ }
128
144
  }
129
145
 
146
+ /** Run `gh` and return stdout. On non-zero exit, throws a `GhError` carrying the toolcall input and raw command. */
130
147
  async function ghExec(
131
- pi: ExtensionAPI,
132
148
  args: string[],
133
- ctx: { cwd?: string; signal?: AbortSignal },
134
- ): Promise<GhExecResult> {
135
- const result = await execGh(pi, args, ctx);
149
+ ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
150
+ ): Promise<string> {
151
+ const result = await runGh(args, ctx);
136
152
  if (result.code !== 0) {
137
- return {
138
- ok: false,
139
- stdout: "",
140
- error: result.combined.trim() || `exit code ${result.code}`,
141
- args,
142
- };
153
+ throw new GhError(args, result, ctx.input);
143
154
  }
144
- return { ok: true, stdout: result.stdout, error: null, args };
155
+ return result.stdout;
145
156
  }
146
157
 
147
158
  function repoArgs(repo?: string): string[] {
148
159
  return repo ? ["--repo", repo] : [];
149
160
  }
150
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
+
151
183
  function truncate(
152
184
  text: string,
153
185
  maxLines = 2000,
@@ -171,22 +203,14 @@ function truncate(
171
203
  }
172
204
 
173
205
  /**
174
- * Convert a gh invocation result into a tool result.
175
- *
176
- * On failure, returns the error text as content and includes the raw command
177
- * input (`args`) in `details` so the user can debug what the model actually ran.
206
+ * Format a successful gh invocation's stdout into a tool result.
207
+ * Failures are thrown by `ghExec` as `GhError`, so only the success path lives here.
178
208
  */
179
- function toToolResult(res: GhExecResult): {
209
+ function toToolResult(stdout: string): {
180
210
  content: Array<{ type: "text"; text: string }>;
181
211
  details: Record<string, unknown>;
182
212
  } {
183
- if (!res.ok) {
184
- return {
185
- content: [{ type: "text", text: `gh ${res.args.join(" ")} failed: ${res.error}` }],
186
- details: { error: res.error, args: res.args },
187
- };
188
- }
189
- const { text, truncated } = truncate(res.stdout);
213
+ const { text, truncated } = truncate(stdout);
190
214
  return { content: [{ type: "text", text }], details: { truncated } };
191
215
  }
192
216
 
@@ -220,16 +244,16 @@ export function stepsDetail(
220
244
  }
221
245
 
222
246
  /** In-flight dedup map to avoid concurrent fetches of the same log. */
223
- const inflightLogs = new Map<string, Promise<GhExecResult & { log?: string }>>();
247
+ const inflightLogs = new Map<string, Promise<string>>();
224
248
 
225
249
  async function getJobLog(
226
- pi: ExtensionAPI,
227
250
  runId: string,
228
251
  jobId: number,
229
252
  effectiveRepo: string,
230
253
  signal: AbortSignal | undefined,
231
254
  cwd: string | undefined,
232
- ): Promise<GhExecResult & { log?: string }> {
255
+ input?: unknown,
256
+ ): Promise<string> {
233
257
  const cacheDir = join(homedir(), ".cache", "pi", "ci-logs", runId);
234
258
  const cacheFile = join(cacheDir, `${jobId}.log`);
235
259
  const key = `${runId}:${jobId}`;
@@ -238,28 +262,27 @@ async function getJobLog(
238
262
  const inflight = inflightLogs.get(key);
239
263
  if (inflight) return inflight;
240
264
 
241
- const fetchAndCache = async (): Promise<GhExecResult & { log?: string }> => {
265
+ const fetchAndCache = async (): Promise<string> => {
242
266
  // Check file cache
243
267
  try {
244
- const cached = await readFile(cacheFile, "utf-8");
245
- return { ok: true, stdout: cached, error: null, args: [], log: cached };
268
+ return await readFile(cacheFile, "utf-8");
246
269
  } catch {
247
270
  // Not cached, fetch from GitHub
248
271
  }
249
272
 
250
- const res = await ghExec(pi, ["api", `/repos/${effectiveRepo}/actions/jobs/${jobId}/logs`], {
273
+ const log = await ghExec(["api", `/repos/${effectiveRepo}/actions/jobs/${jobId}/logs`], {
251
274
  cwd,
252
275
  signal,
276
+ input,
253
277
  });
254
- if (!res.ok) return res;
255
278
 
256
279
  // Write to cache
257
280
  await mkdir(cacheDir, { recursive: true });
258
281
  await withFileMutationQueue(cacheFile, async () => {
259
- await writeFile(cacheFile, res.stdout);
282
+ await writeFile(cacheFile, log);
260
283
  });
261
284
 
262
- return { ...res, log: res.stdout };
285
+ return log;
263
286
  };
264
287
 
265
288
  const promise = fetchAndCache();
@@ -272,15 +295,15 @@ async function getJobLog(
272
295
  }
273
296
 
274
297
  async function resolveRepo(
275
- pi: ExtensionAPI,
276
298
  repo: string | undefined,
277
299
  signal: AbortSignal | undefined,
278
300
  cwd: string | undefined,
279
- ): Promise<GhExecResult & { repo?: string }> {
280
- if (repo) return { ok: true, stdout: "", error: null, args: [], repo };
281
- const res = await ghExec(pi, ["repo", "view", "--json", "nameWithOwner"], { cwd, signal });
282
- if (!res.ok) return res;
283
- return { ...res, repo: JSON.parse(res.stdout).nameWithOwner };
301
+ input?: unknown,
302
+ ): Promise<string> {
303
+ if (repo) return repo;
304
+ const stdout = await ghExec(["repo", "view", "--json", "nameWithOwner"], { cwd, signal, input });
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,10 +423,9 @@ 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
- pi,
407
429
  [
408
430
  "issue",
409
431
  "view",
@@ -412,7 +434,7 @@ export default function (pi: ExtensionAPI) {
412
434
  "--json",
413
435
  "title,state,body,author,createdAt,updatedAt,closedAt,url,labels,assignees,comments,milestone,number",
414
436
  ],
415
- { cwd: ctx.cwd, signal },
437
+ { cwd: ctx.cwd, signal, input: params },
416
438
  ),
417
439
  );
418
440
  },
@@ -430,11 +452,11 @@ export default function (pi: ExtensionAPI) {
430
452
  limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
431
453
  }),
432
454
  async execute(_id, params, signal, _onUpdate, ctx) {
433
- const { repo, state, limit } = params as { repo?: string; state?: string; limit?: number };
455
+ const { repo, state, limit } = params;
434
456
  const args = ["issue", "list", ...repoArgs(repo)];
435
457
  if (state) args.push("--state", state);
436
458
  if (limit) args.push("--limit", String(limit));
437
- return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
459
+ return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
438
460
  },
439
461
  });
440
462
 
@@ -449,10 +471,9 @@ export default function (pi: ExtensionAPI) {
449
471
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
450
472
  }),
451
473
  async execute(_id, params, signal, _onUpdate, ctx) {
452
- const { number, repo } = params as { number: number | string; repo?: string };
474
+ const { number, repo } = params;
453
475
  return toToolResult(
454
476
  await ghExec(
455
- pi,
456
477
  [
457
478
  "pr",
458
479
  "view",
@@ -461,7 +482,7 @@ export default function (pi: ExtensionAPI) {
461
482
  "--json",
462
483
  "title,state,body,author,createdAt,updatedAt,mergedAt,mergedBy,headRefName,baseRefName,url,additions,deletions,changedFiles,labels,assignees,reviewRequests,reviews,comments,number",
463
484
  ],
464
- { cwd: ctx.cwd, signal },
485
+ { cwd: ctx.cwd, signal, input: params },
465
486
  ),
466
487
  );
467
488
  },
@@ -481,11 +502,11 @@ export default function (pi: ExtensionAPI) {
481
502
  limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
482
503
  }),
483
504
  async execute(_id, params, signal, _onUpdate, ctx) {
484
- const { repo, state, limit } = params as { repo?: string; state?: string; limit?: number };
505
+ const { repo, state, limit } = params;
485
506
  const args = ["pr", "list", ...repoArgs(repo)];
486
507
  if (state) args.push("--state", state);
487
508
  if (limit) args.push("--limit", String(limit));
488
- return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
509
+ return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
489
510
  },
490
511
  });
491
512
 
@@ -500,12 +521,9 @@ export default function (pi: ExtensionAPI) {
500
521
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
501
522
  }),
502
523
  async execute(_id, params, signal, _onUpdate, ctx) {
503
- const { number, repo } = params as {
504
- number: number | string;
505
- repo?: string;
506
- };
524
+ const { number, repo } = params;
507
525
  const args = ["pr", "diff", String(number), ...repoArgs(repo)];
508
- return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
526
+ return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
509
527
  },
510
528
  });
511
529
 
@@ -520,11 +538,12 @@ export default function (pi: ExtensionAPI) {
520
538
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
521
539
  }),
522
540
  async execute(_id, params, signal, _onUpdate, ctx) {
523
- const { number, repo } = params as { number: number | string; repo?: string };
541
+ const { number, repo } = params;
524
542
  return toToolResult(
525
- await ghExec(pi, ["pr", "checks", String(number), ...repoArgs(repo)], {
543
+ await ghExec(["pr", "checks", String(number), ...repoArgs(repo)], {
526
544
  cwd: ctx.cwd,
527
545
  signal,
546
+ input: params,
528
547
  }),
529
548
  );
530
549
  },
@@ -548,31 +567,26 @@ export default function (pi: ExtensionAPI) {
548
567
  ),
549
568
  }),
550
569
  async execute(_id, params, signal, _onUpdate, ctx) {
551
- const { number, repo, reviews } = params as {
552
- number: number | string;
553
- repo?: string;
554
- reviews?: boolean;
555
- };
570
+ const { number, repo, reviews } = params;
556
571
  let out: string;
557
572
  if (reviews) {
558
- const resolved = await resolveRepo(pi, repo, signal, ctx.cwd);
559
- if (!resolved.ok) return toToolResult(resolved);
573
+ const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
560
574
 
561
- const [commentsRes, reviewsRes] = await Promise.all([
562
- ghExec(pi, ["api", `/repos/${resolved.repo!}/pulls/${String(number)}/comments`], {
575
+ const [comments, reviewsOut] = await Promise.all([
576
+ ghExec(["api", `/repos/${effectiveRepo}/pulls/${String(number)}/comments`], {
563
577
  cwd: ctx.cwd,
564
578
  signal,
579
+ input: params,
565
580
  }),
566
- ghExec(pi, ["api", `/repos/${resolved.repo!}/pulls/${String(number)}/reviews`], {
581
+ ghExec(["api", `/repos/${effectiveRepo}/pulls/${String(number)}/reviews`], {
567
582
  cwd: ctx.cwd,
568
583
  signal,
584
+ input: params,
569
585
  }),
570
586
  ]);
571
- if (!commentsRes.ok) return toToolResult(commentsRes);
572
- if (!reviewsRes.ok) return toToolResult(reviewsRes);
573
587
 
574
- const reviewComments = JSON.parse(commentsRes.stdout);
575
- const reviewSummaries = JSON.parse(reviewsRes.stdout);
588
+ const reviewComments = Value.Parse(Type.Array(Type.Unknown()), JSON.parse(comments));
589
+ const reviewSummaries = Value.Parse(Type.Array(Type.Unknown()), JSON.parse(reviewsOut));
576
590
 
577
591
  out = JSON.stringify(
578
592
  {
@@ -583,16 +597,14 @@ export default function (pi: ExtensionAPI) {
583
597
  2,
584
598
  );
585
599
  } else {
586
- const res = await ghExec(
587
- pi,
600
+ out = await ghExec(
588
601
  ["pr", "view", String(number), ...repoArgs(repo), "--json", "comments"],
589
602
  {
590
603
  cwd: ctx.cwd,
591
604
  signal,
605
+ input: params,
592
606
  },
593
607
  );
594
- if (!res.ok) return toToolResult(res);
595
- out = res.stdout;
596
608
  }
597
609
  const { text, truncated } = truncate(out);
598
610
  return {
@@ -613,13 +625,13 @@ export default function (pi: ExtensionAPI) {
613
625
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
614
626
  }),
615
627
  async execute(_id, params, signal, _onUpdate, ctx) {
616
- const { number, repo } = params as { number: number | string; repo?: string };
628
+ const { number, repo } = params;
617
629
  return toToolResult(
618
- await ghExec(
619
- pi,
620
- ["issue", "view", String(number), ...repoArgs(repo), "--json", "comments"],
621
- { cwd: ctx.cwd, signal },
622
- ),
630
+ await ghExec(["issue", "view", String(number), ...repoArgs(repo), "--json", "comments"], {
631
+ cwd: ctx.cwd,
632
+ signal,
633
+ input: params,
634
+ }),
623
635
  );
624
636
  },
625
637
  });
@@ -639,17 +651,12 @@ export default function (pi: ExtensionAPI) {
639
651
  workflow: Type.Optional(Type.String({ description: "Filter by workflow name or file" })),
640
652
  }),
641
653
  async execute(_id, params, signal, _onUpdate, ctx) {
642
- const { repo, limit, status, workflow } = params as {
643
- repo?: string;
644
- limit?: number;
645
- status?: string;
646
- workflow?: string;
647
- };
654
+ const { repo, limit, status, workflow } = params;
648
655
  const args = ["run", "list", ...repoArgs(repo)];
649
656
  if (limit) args.push("--limit", String(limit));
650
657
  if (status) args.push("--status", status);
651
658
  if (workflow) args.push("--workflow", workflow);
652
- return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
659
+ return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
653
660
  },
654
661
  });
655
662
 
@@ -689,40 +696,17 @@ export default function (pi: ExtensionAPI) {
689
696
  ),
690
697
  }),
691
698
  async execute(_id, params, signal, onUpdate, ctx) {
692
- const { run_id, repo, job, step, offset, limit } = params as {
693
- run_id: number | string;
694
- repo?: string;
695
- job?: string;
696
- step?: string;
697
- offset?: number;
698
- limit?: number;
699
- };
699
+ const { run_id, repo, job, step, offset, limit } = params;
700
700
 
701
701
  // ── Fetch specific step logs ───────────────────────────────────────
702
702
  if (step !== undefined && step !== null) {
703
- const resolved = await resolveRepo(pi, repo, signal, ctx.cwd);
704
- if (!resolved.ok) return toToolResult(resolved);
703
+ const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
705
704
 
706
- const jobsRes = await ghExec(
707
- pi,
708
- ["api", `/repos/${resolved.repo!}/actions/runs/${run_id}/jobs`],
709
- { cwd: ctx.cwd, signal },
705
+ const jobsOut = await ghExec(
706
+ ["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`],
707
+ { cwd: ctx.cwd, signal, input: params },
710
708
  );
711
- if (!jobsRes.ok) return toToolResult(jobsRes);
712
- const { jobs } = JSON.parse(jobsRes.stdout) as {
713
- jobs: Array<{
714
- id: number;
715
- name: string;
716
- status: string;
717
- conclusion: string | null;
718
- steps: Array<{
719
- name: string;
720
- number: number;
721
- status: string;
722
- conclusion: string | null;
723
- }>;
724
- }>;
725
- };
709
+ const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
726
710
 
727
711
  if (!jobs || jobs.length === 0) {
728
712
  return {
@@ -804,16 +788,14 @@ export default function (pi: ExtensionAPI) {
804
788
  details: {},
805
789
  });
806
790
 
807
- const logRes = await getJobLog(
808
- pi,
791
+ const rawLog = await getJobLog(
809
792
  String(run_id),
810
793
  targetJob.id,
811
- resolved.repo!,
794
+ effectiveRepo,
812
795
  signal,
813
796
  ctx.cwd,
797
+ params,
814
798
  );
815
- if (!logRes.ok) return toToolResult(logRes);
816
- const rawLog = logRes.log!;
817
799
 
818
800
  const stepLog = extractStepFromLog(rawLog, stepNum, targetJob.steps);
819
801
  if (stepLog === null) {
@@ -891,29 +873,14 @@ export default function (pi: ExtensionAPI) {
891
873
  details: {},
892
874
  });
893
875
 
894
- const resolved = await resolveRepo(pi, repo, signal, ctx.cwd);
895
- if (!resolved.ok) return toToolResult(resolved);
876
+ const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
896
877
 
897
- const jobsRes = await ghExec(
898
- pi,
899
- ["api", `/repos/${resolved.repo!}/actions/runs/${run_id}/jobs`],
900
- { cwd: ctx.cwd, signal },
901
- );
902
- if (!jobsRes.ok) return toToolResult(jobsRes);
903
- const { jobs } = JSON.parse(jobsRes.stdout) as {
904
- jobs: Array<{
905
- id: number;
906
- name: string;
907
- status: string;
908
- conclusion: string | null;
909
- steps: Array<{
910
- name: string;
911
- number: number;
912
- status: string;
913
- conclusion: string | null;
914
- }>;
915
- }>;
916
- };
878
+ const jobsOut = await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
879
+ cwd: ctx.cwd,
880
+ signal,
881
+ input: params,
882
+ });
883
+ const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
917
884
 
918
885
  if (!jobs || jobs.length === 0) {
919
886
  return {
@@ -974,15 +941,14 @@ export default function (pi: ExtensionAPI) {
974
941
  if (failedSteps.length === 0) continue;
975
942
 
976
943
  try {
977
- const logRes = await getJobLog(pi, String(run_id), j.id, resolved.repo!, signal, ctx.cwd);
978
- if (!logRes.ok) {
979
- contents.push({
980
- type: "text",
981
- text: `\n⚠️ Could not auto-fetch logs for ${j.name}: gh ${logRes.args.join(" ")} failed: ${logRes.error}`,
982
- });
983
- continue;
984
- }
985
- const rawLog = logRes.log!;
944
+ const rawLog = await getJobLog(
945
+ String(run_id),
946
+ j.id,
947
+ effectiveRepo,
948
+ signal,
949
+ ctx.cwd,
950
+ params,
951
+ );
986
952
 
987
953
  for (const fs of failedSteps) {
988
954
  if (fetchedCount >= maxFailed) break;
@@ -1046,13 +1012,13 @@ export default function (pi: ExtensionAPI) {
1046
1012
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1047
1013
  }),
1048
1014
  async execute(_id, params, signal, _onUpdate, ctx) {
1049
- const { run_id, repo } = params as { run_id: number | string; repo?: string };
1050
- const resolved = await resolveRepo(pi, repo, signal, ctx.cwd);
1051
- if (!resolved.ok) return toToolResult(resolved);
1015
+ const { run_id, repo } = params;
1016
+ const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1052
1017
  return toToolResult(
1053
- await ghExec(pi, ["api", `/repos/${resolved.repo!}/actions/runs/${run_id}/jobs`], {
1018
+ await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
1054
1019
  cwd: ctx.cwd,
1055
1020
  signal,
1021
+ input: params,
1056
1022
  }),
1057
1023
  );
1058
1024
  },
@@ -1068,10 +1034,10 @@ export default function (pi: ExtensionAPI) {
1068
1034
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1069
1035
  }),
1070
1036
  async execute(_id, params, signal, _onUpdate, ctx) {
1071
- const { repo } = params as { repo?: string };
1037
+ const { repo } = params;
1072
1038
  const args = ["repo", "view"];
1073
1039
  if (repo) args.push(repo);
1074
- return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
1040
+ return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
1075
1041
  },
1076
1042
  });
1077
1043
 
@@ -1086,10 +1052,10 @@ export default function (pi: ExtensionAPI) {
1086
1052
  limit: Type.Optional(Type.Number({ description: "Max results (default 10)" })),
1087
1053
  }),
1088
1054
  async execute(_id, params, signal, _onUpdate, ctx) {
1089
- const { repo, limit } = params as { repo?: string; limit?: number };
1055
+ const { repo, limit } = params;
1090
1056
  const args = ["release", "list", ...repoArgs(repo)];
1091
1057
  if (limit) args.push("--limit", String(limit));
1092
- return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
1058
+ return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
1093
1059
  },
1094
1060
  });
1095
1061
 
@@ -1104,11 +1070,12 @@ export default function (pi: ExtensionAPI) {
1104
1070
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1105
1071
  }),
1106
1072
  async execute(_id, params, signal, _onUpdate, ctx) {
1107
- const { tag, repo } = params as { tag: string; repo?: string };
1073
+ const { tag, repo } = params;
1108
1074
  return toToolResult(
1109
- await ghExec(pi, ["release", "view", tag, ...repoArgs(repo)], {
1075
+ await ghExec(["release", "view", tag, ...repoArgs(repo)], {
1110
1076
  cwd: ctx.cwd,
1111
1077
  signal,
1078
+ input: params,
1112
1079
  }),
1113
1080
  );
1114
1081
  },
@@ -1130,11 +1097,7 @@ export default function (pi: ExtensionAPI) {
1130
1097
  ),
1131
1098
  }),
1132
1099
  async execute(_id, params, signal, onUpdate, ctx) {
1133
- const { number, repo, fail_fast } = params as {
1134
- number: number | string;
1135
- repo?: string;
1136
- fail_fast?: boolean;
1137
- };
1100
+ const { number, repo, fail_fast } = params;
1138
1101
 
1139
1102
  onUpdate?.({
1140
1103
  content: [{ type: "text", text: `Watching CI checks for PR #${number}...` }],
@@ -1144,7 +1107,7 @@ export default function (pi: ExtensionAPI) {
1144
1107
  const args = ["pr", "checks", String(number), ...repoArgs(repo), "--watch"];
1145
1108
  if (fail_fast) args.push("--fail-fast");
1146
1109
 
1147
- const result = await execGh(pi, args, { cwd: ctx.cwd, signal, timeout: 600_000 });
1110
+ const result = await runGh(args, { cwd: ctx.cwd, signal, timeout: 600_000 });
1148
1111
 
1149
1112
  const exitCode = result.code;
1150
1113
  const stdout = result.stdout;
@@ -1184,14 +1147,14 @@ export default function (pi: ExtensionAPI) {
1184
1147
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1185
1148
  }),
1186
1149
  async execute(_id, params, signal, onUpdate, ctx) {
1187
- const { run_id, repo } = params as { run_id: number | string; repo?: string };
1150
+ const { run_id, repo } = params;
1188
1151
 
1189
1152
  onUpdate?.({
1190
1153
  content: [{ type: "text", text: `Watching workflow run ${run_id}...` }],
1191
1154
  details: {},
1192
1155
  });
1193
1156
 
1194
- const result = await execGh(pi, ["run", "watch", String(run_id), ...repoArgs(repo)], {
1157
+ const result = await runGh(["run", "watch", String(run_id), ...repoArgs(repo)], {
1195
1158
  cwd: ctx.cwd,
1196
1159
  signal,
1197
1160
  timeout: 600_000,
@@ -1229,15 +1192,11 @@ export default function (pi: ExtensionAPI) {
1229
1192
  limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
1230
1193
  }),
1231
1194
  async execute(_id, params, signal, _onUpdate, ctx) {
1232
- const { query, include_prs, limit } = params as {
1233
- query: string;
1234
- include_prs?: boolean;
1235
- limit?: number;
1236
- };
1195
+ const { query, include_prs, limit } = params;
1237
1196
  const args = ["search", "issues", query];
1238
1197
  if (include_prs) args.push("--include-prs");
1239
1198
  if (limit) args.push("--limit", String(limit));
1240
- return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
1199
+ return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
1241
1200
  },
1242
1201
  });
1243
1202
 
@@ -1255,10 +1214,10 @@ export default function (pi: ExtensionAPI) {
1255
1214
  limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
1256
1215
  }),
1257
1216
  async execute(_id, params, signal, _onUpdate, ctx) {
1258
- const { query, limit } = params as { query: string; limit?: number };
1217
+ const { query, limit } = params;
1259
1218
  const args = ["search", "prs", query];
1260
1219
  if (limit) args.push("--limit", String(limit));
1261
- return toToolResult(await ghExec(pi, args, { cwd: ctx.cwd, signal }));
1220
+ return toToolResult(await ghExec(args, { cwd: ctx.cwd, signal, input: params }));
1262
1221
  },
1263
1222
  });
1264
1223
  }
@@ -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);