@trim21/personal-pi-extensions 0.0.183 → 0.0.186

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.183",
3
+ "version": "0.0.186",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -11,11 +11,12 @@
11
11
  *
12
12
  * ## Modes
13
13
  *
14
- * Three modes, switchable at runtime:
14
+ * Four modes, switchable at runtime:
15
15
  *
16
16
  * allow-all No sandbox. Network allowed. All commands run natively.
17
17
  * workspace-write Sandbox enabled, network blocked. Project dir + /tmp writable.
18
18
  * Model can request full access via bash tool parameter.
19
+ * allow-net Sandbox enabled, network allowed. Project dir + /tmp writable.
19
20
  * readonly Sandbox enabled, network blocked, no writable paths.
20
21
  *
21
22
  * ## Escalation
@@ -45,6 +46,7 @@
45
46
  * /bwrap Show current mode and paths
46
47
  * /bwrap-allow-all Full access, sandbox off
47
48
  * /bwrap-workspace-write Sandbox on, workspace writable
49
+ * /bwrap-allow-net Sandbox on, network on, workspace writable
48
50
  * /bwrap-readonly Sandbox on, no writes
49
51
  *
50
52
  * Usage:
@@ -73,8 +75,9 @@ Three sandbox modes exist:
73
75
  - allow-all: sandbox off, network on, full access
74
76
  - readonly: sandbox on, network off, nothing writable
75
77
  - workspace-write: sandbox on, network off, only workspace and /tmp writable
78
+ - allow-net: sandbox on, network on, only workspace and /tmp writable
76
79
 
77
- In workspace-write and readonly modes, the bash tool has a
80
+ In workspace-write, allow-net and readonly modes, the bash tool has a
78
81
  \`request_full_access\` boolean parameter.
79
82
  Set it to true to request execution outside the sandbox.
80
83
  The user must approve.
@@ -92,7 +95,8 @@ require request_full_access: true.
92
95
 
93
96
  the \`request_full_access\` is only needed for:
94
97
  - Writing to paths outside the configured writable directories,
95
- - Operations requiring network access (curl, npm install, git push, etc.).
98
+ - Operations requiring network access when the current mode blocks it
99
+ (workspace-write or readonly; allow-all and allow-net already have network).
96
100
  Writing inside the workspace or /tmp, or reading any file, does not require escalation,
97
101
  for example, the simple \`ls\`, \`cat\`, \`find\` or \`grep\` and git command that only read from .git directory but not change .git directory and other read only commands.
98
102
  **If the command is readonly operator, do not use \`request_full_access\`**
@@ -146,7 +150,7 @@ function findBwrap(override?: string): string {
146
150
  return override ?? bwrapPath;
147
151
  }
148
152
 
149
- type BwrapMode = "allow-all" | "workspace-write" | "readonly";
153
+ type BwrapMode = "allow-all" | "workspace-write" | "allow-net" | "readonly";
150
154
 
151
155
  export interface BwrapConfig {
152
156
  mode: BwrapMode;
@@ -168,7 +172,7 @@ export interface ResolvedBwrap {
168
172
  extraArgs: string[];
169
173
  }
170
174
 
171
- function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
175
+ export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
172
176
  const base = {
173
177
  mode: config.mode,
174
178
  bwrapPath: config.bwrapPath,
@@ -184,6 +188,9 @@ function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
184
188
  case "workspace-write": {
185
189
  return { ...base, bwrapEnabled: true, network: false };
186
190
  }
191
+ case "allow-net": {
192
+ return { ...base, bwrapEnabled: true, network: true };
193
+ }
187
194
  case "readonly": {
188
195
  return { ...base, bwrapEnabled: true, network: false, writablePaths: [] };
189
196
  }
@@ -508,6 +515,7 @@ function notifyMode(
508
515
  const labels: Record<BwrapMode, string> = {
509
516
  "allow-all": "allow-all: sandbox off, network on",
510
517
  "workspace-write": "workspace-write: sandbox on, network off",
518
+ "allow-net": "allow-net: sandbox on, network on, workspace writable",
511
519
  readonly: "readonly: sandbox on, network off, read-only fs",
512
520
  };
513
521
  ctx.ui.notify(labels[mode], "info");
@@ -768,6 +776,11 @@ export default function bwrapExtension(pi: ExtensionAPI) {
768
776
  handler: (_args, ctx) => Promise.resolve(switchMode("workspace-write", ctx)),
769
777
  });
770
778
 
779
+ pi.registerCommand("bwrap-allow-net", {
780
+ description: "Sandbox on, network on, workspace writable",
781
+ handler: (_args, ctx) => Promise.resolve(switchMode("allow-net", ctx)),
782
+ });
783
+
771
784
  pi.registerCommand("bwrap-readonly", {
772
785
  description: "Sandbox on, network off, no writes",
773
786
  handler: (_args, ctx) => Promise.resolve(switchMode("readonly", ctx)),
@@ -206,11 +206,22 @@ const jobRunSchema = Type.Object({
206
206
  name: Type.String(),
207
207
  status: Type.String(),
208
208
  conclusion: Type.Union([Type.String(), Type.Null()]),
209
+ html_url: Type.Optional(Type.String()),
209
210
  steps: Type.Array(stepSchema),
210
211
  });
211
212
 
212
213
  const jobsResponseSchema = Type.Object({ jobs: Type.Array(jobRunSchema) });
213
214
 
215
+ const prHeadSchema = Type.Object({ headRefOid: Type.String() });
216
+
217
+ const workflowRunSchema = Type.Object({
218
+ id: Type.Number(),
219
+ name: Type.String(),
220
+ html_url: Type.String(),
221
+ });
222
+
223
+ const workflowRunsSchema = Type.Object({ workflow_runs: Type.Array(workflowRunSchema) });
224
+
214
225
  function truncate(
215
226
  text: string,
216
227
  maxLines = 2000,
@@ -401,6 +412,15 @@ async function resolveRepo(
401
412
  return nameWithOwner;
402
413
  }
403
414
 
415
+ /** Job conclusions that count as "did not succeed" for CI result reporting. */
416
+ const FAILED_JOB_CONCLUSIONS = new Set([
417
+ "failure",
418
+ "timed_out",
419
+ "action_required",
420
+ "startup_failure",
421
+ "cancelled",
422
+ ]);
423
+
404
424
  export function statusIcon(conclusion: string | null): string {
405
425
  switch (conclusion) {
406
426
  case "success": {
@@ -1311,29 +1331,115 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1311
1331
  const args = ["pr", "checks", String(number), ...repoArgs(repo), "--watch"];
1312
1332
  if (fail_fast) args.push("--fail-fast");
1313
1333
 
1334
+ // gh 的退出码语义不可靠:checks 失败时返回 exit 1(SilentError),挂起时
1335
+ // 返回 exit 8(PendingError),且失败详情只在非结构化的 stdout 表格里。
1336
+ // 因此 watch 退出后直接用 Actions API 抓取该 PR head 提交关联的所有
1337
+ // workflow job,以 job 的真实 conclusion 为准判断成功/失败。
1314
1338
  const result = await runGh(args, { cwd: ctx.cwd, signal, timeout: 600_000 });
1339
+ if (result.killed) {
1340
+ throw new Error(
1341
+ result.reason === "timeout"
1342
+ ? "gh pr checks --watch timed out after 10 minutes"
1343
+ : "gh pr checks --watch was aborted",
1344
+ );
1345
+ }
1315
1346
 
1316
- const exitCode = result.code;
1317
- const stdout = result.stdout;
1318
- const stderr = result.stderr;
1347
+ const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1348
+ const prOut = await ghExec(
1349
+ ["pr", "view", String(number), "--repo", effectiveRepo, "--json", "headRefOid"],
1350
+ { cwd: ctx.cwd, signal, input: params },
1351
+ );
1352
+ const { headRefOid } = Value.Parse(prHeadSchema, JSON.parse(prOut));
1353
+
1354
+ onUpdate?.({
1355
+ content: [{ type: "text", text: `Fetching workflow jobs for PR #${number}...` }],
1356
+ details: {},
1357
+ });
1358
+
1359
+ const runsOut = await ghExec(
1360
+ ["api", `/repos/${effectiveRepo}/actions/runs?head_sha=${headRefOid}&per_page=100`],
1361
+ { cwd: ctx.cwd, signal, input: params },
1362
+ );
1363
+ const { workflow_runs } = Value.Parse(workflowRunsSchema, JSON.parse(runsOut));
1364
+
1365
+ const failedJobs: {
1366
+ runId: number;
1367
+ runName: string;
1368
+ runUrl: string;
1369
+ jobId: number;
1370
+ jobName: string;
1371
+ conclusion: string;
1372
+ jobUrl?: string;
1373
+ }[] = [];
1374
+ let totalJobs = 0;
1375
+
1376
+ for (const run of workflow_runs) {
1377
+ const jobsOut = await ghExec(
1378
+ ["api", `/repos/${effectiveRepo}/actions/runs/${run.id}/jobs?per_page=100`],
1379
+ { cwd: ctx.cwd, signal, input: params },
1380
+ );
1381
+ const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
1382
+ totalJobs += jobs.length;
1383
+
1384
+ for (const job of jobs) {
1385
+ if (!job.conclusion || FAILED_JOB_CONCLUSIONS.has(job.conclusion)) {
1386
+ failedJobs.push({
1387
+ runId: run.id,
1388
+ runName: run.name,
1389
+ runUrl: run.html_url,
1390
+ jobId: job.id,
1391
+ jobName: job.name,
1392
+ conclusion: job.conclusion ?? "in_progress",
1393
+ jobUrl: job.html_url,
1394
+ });
1395
+ }
1396
+ }
1397
+ }
1319
1398
 
1320
- // Exit code 2 means one or more checks failed
1321
- if (exitCode === 2) {
1399
+ if (totalJobs === 0) {
1322
1400
  return {
1323
1401
  content: [
1324
- { type: "text", text: `## PR #${number} CI Checks - FAILED\n\n${stdout}\n${stderr}` },
1402
+ {
1403
+ type: "text",
1404
+ text: `## PR #${number} CI Checks\n\nNo workflow runs found for head commit ${headRefOid.slice(0, 7)}.`,
1405
+ },
1325
1406
  ],
1326
- details: { status: "failure", exitCode, input: params },
1407
+ details: { status: "no-jobs", totalJobs: 0, input: params },
1327
1408
  };
1328
1409
  }
1329
1410
 
1330
- if (exitCode !== 0) {
1331
- throw new Error(`gh pr checks --watch failed: ${stderr || `exit code ${exitCode}`}`);
1411
+ if (failedJobs.length > 0) {
1412
+ const lines = failedJobs.map(
1413
+ (j) =>
1414
+ `- ${statusIcon(j.conclusion)} **${j.jobName}** (${j.conclusion}) — [job #${j.jobId}](${j.jobUrl ?? j.runUrl})\n` +
1415
+ ` - workflow: [${j.runName} (#${j.runId})](${j.runUrl})`,
1416
+ );
1417
+ return {
1418
+ content: [
1419
+ {
1420
+ type: "text",
1421
+ text:
1422
+ `## PR #${number} CI Checks - FAILED\n\n` +
1423
+ `${failedJobs.length} of ${totalJobs} job(s) did not succeed:\n\n${lines.join("\n")}`,
1424
+ },
1425
+ ],
1426
+ details: {
1427
+ status: "failure",
1428
+ totalJobs,
1429
+ failedJobs,
1430
+ input: params,
1431
+ },
1432
+ };
1332
1433
  }
1333
1434
 
1334
1435
  return {
1335
- content: [{ type: "text", text: `## PR #${number} CI Checks - PASSED\n\n${stdout}` }],
1336
- details: { status: "success", exitCode: 0, input: params },
1436
+ content: [
1437
+ {
1438
+ type: "text",
1439
+ text: `## PR #${number} CI Checks - PASSED\n\nAll ${totalJobs} job(s) succeeded.`,
1440
+ },
1441
+ ],
1442
+ details: { status: "success", totalJobs, input: params },
1337
1443
  };
1338
1444
  },
1339
1445
  });