@trim21/personal-pi-extensions 0.1.551 → 0.1.553

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.1.551",
3
+ "version": "0.1.553",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -17,7 +17,7 @@ import { type TObject, Type } from "typebox";
17
17
 
18
18
  import { type CommandSpec, parseCommand } from "../lib/cli.js";
19
19
  import { fenceCodeBlock } from "../lib/markdown.js";
20
- import { formatDisplayPath } from "../lib/path.js";
20
+ import { formatDisplayPath, resolveHomePath } from "../lib/path.js";
21
21
  import { type SelectAction, selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
22
22
  import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-rules.js";
23
23
  import { commandPatternsFor } from "./approval-suggest.js";
@@ -91,6 +91,8 @@ export interface BwrapExecutionRequest {
91
91
  */
92
92
  export interface BwrapExecutionResult {
93
93
  exitCode: number | null;
94
+ /** 只描述写边界(工作区外能否写)与网络层级(关 / 白名单 / 放开),不含具体域名;未沙箱执行时为 undefined。 */
95
+ sandboxHint: string | undefined;
94
96
  /** 截断后的输出(尾部),未截断时为完整输出;空输出为空字符串。 */
95
97
  output: string;
96
98
  /** 完整输出的文件路径;无输出时不存在。 */
@@ -112,16 +114,19 @@ export interface BashExecutionPartial {
112
114
  export class BashInterruptedError extends Error {
113
115
  readonly kind: "timeout" | "aborted";
114
116
  readonly partial: BashExecutionPartial;
117
+ readonly sandboxHint: string | undefined;
115
118
 
116
119
  constructor(
117
120
  kind: "timeout" | "aborted",
118
121
  message: string,
119
122
  partial: BashExecutionPartial,
123
+ sandboxHint: string | undefined,
120
124
  cause: unknown,
121
125
  ) {
122
126
  super(message, { cause });
123
127
  this.kind = kind;
124
128
  this.partial = partial;
129
+ this.sandboxHint = sandboxHint;
125
130
  // 对齐标准错误分类:中断=AbortError(用户取消),超时=TimeoutError
126
131
  this.name = kind === "aborted" ? "AbortError" : "TimeoutError";
127
132
  }
@@ -255,6 +260,67 @@ function notifyMode(
255
260
  ctx.ui.notify(labels[mode], "info");
256
261
  }
257
262
 
263
+ /** 沙箱内允许写入的根目录(与沙箱层同基准:相对 workspace 解析 "."、`~` 与相对路径)。 */
264
+ function writableRoots(resolved: ResolvedBwrap, workspace: string): string[] {
265
+ const writable = [...resolved.writablePaths, ...resolved.extraWritablePaths].map((path) =>
266
+ resolveHomePath(path, workspace),
267
+ );
268
+ return [...new Set(writable)];
269
+ }
270
+
271
+ /** 网络层级:关 / 只放行白名单 / 完全放开(白名单域名本身不列出,对判断失败无用)。 */
272
+ function describeNetwork(resolved: ResolvedBwrap): string {
273
+ if (!resolved.network) return "network access is off";
274
+ if (resolved.networkAllowlist.length > 0)
275
+ return "network access is limited to allowlisted addresses";
276
+ return "network access is unrestricted";
277
+ }
278
+
279
+ export interface SandboxHintInput {
280
+ /** writablePaths 中 "." 的解析基准,也是可写根目录的显示基准。 */
281
+ workspace: string;
282
+ /** 本次命令是否绕过沙箱(Windows、审批通过的全权限、allow-all)。 */
283
+ unsandboxed: boolean;
284
+ }
285
+
286
+ /** 命令没经沙箱时沿用 prompt 里的说法,给出在沙盒外重跑的手段。 */
287
+ const SANDBOX_ESCAPE_HATCH =
288
+ "[Sandbox] If the command needs more than that, use the `dangerouslyDisableSandbox` parameter to request unsandboxed execution; the user must approve this request.";
289
+
290
+ /**
291
+ * 命令失败时附在错误文本后的沙箱状态:写边界(可写根目录、.git 只读)+ 网络层级,
292
+ * 以及在沙盒外重跑的手段。只报层级不列白名单域名。
293
+ * 命令没经沙箱(allow-all、审批通过的全权限、Windows)时返回 undefined:
294
+ * 没有沙箱就没什么可提示的。
295
+ */
296
+ export function describeSandbox(
297
+ resolved: ResolvedBwrap,
298
+ input: SandboxHintInput,
299
+ ): string | undefined {
300
+ if (input.unsandboxed) return undefined;
301
+ const roots = writableRoots(resolved, input.workspace);
302
+ const clauses = [
303
+ roots.length === 0
304
+ ? "the filesystem is read-only"
305
+ : `writes are limited to ${roots.map((path) => formatDisplayPath(input.workspace, path)).join(", ")}`,
306
+ // .git 只读保护只在工作区本身可写时才有区分度(readonly 下整个文件系统都不可写)
307
+ ...(roots.includes(input.workspace) ? [".git is read-only"] : []),
308
+ describeNetwork(resolved),
309
+ ];
310
+ return [
311
+ `[Sandbox] This command ran in a sandbox: ${clauses.join("; ")}.`,
312
+ SANDBOX_ESCAPE_HATCH,
313
+ ].join("\n");
314
+ }
315
+
316
+ /**
317
+ * 把沙箱状态拼到失败文本后(未沙箱执行时保持原文)。失败文本常以换行结尾,
318
+ * 先 trimEnd 保证状态与命令输出之间只有一个空行。
319
+ */
320
+ export function appendSandboxHint(text: string, hint: string | undefined): string {
321
+ return hint === undefined ? text : `${text.trimEnd()}\n\n${hint}`;
322
+ }
323
+
258
324
  export class BwrapRuntime {
259
325
  private resolved: ResolvedBwrap | undefined;
260
326
  private sandboxDisabled = false;
@@ -383,6 +449,8 @@ export class BwrapRuntime {
383
449
  runtime.mihomoPath ??= this.mihomoPath ?? findMihomo();
384
450
  this.mihomoPath = runtime.mihomoPath;
385
451
  }
452
+ // 命令失败时附带的沙箱状态(写边界 + 网络层级),由上层拼进错误文本
453
+ const sandboxHint = describeSandbox(runtime, { workspace, unsandboxed: local });
386
454
  await using output = new BashOutput(request.ctx.sessionManager.getSessionId());
387
455
  const { onUpdate } = request;
388
456
 
@@ -415,6 +483,7 @@ export class BwrapRuntime {
415
483
  const partial = await this.finalizeOutput(output);
416
484
  return {
417
485
  exitCode,
486
+ sandboxHint,
418
487
  output: partial.output,
419
488
  ...(partial.fullOutputPath && { fullOutputPath: partial.fullOutputPath }),
420
489
  truncation: partial.truncation,
@@ -433,6 +502,7 @@ export class BwrapRuntime {
433
502
  "timeout",
434
503
  `Command timed out after ${error.message.slice("timeout:".length)} seconds`,
435
504
  partial,
505
+ sandboxHint,
436
506
  error,
437
507
  );
438
508
  }
@@ -440,7 +510,13 @@ export class BwrapRuntime {
440
510
  // 兼容 pi local ops 抛的 new Error("aborted")
441
511
  if (error instanceof Error && (error.name === "AbortError" || error.message === "aborted")) {
442
512
  const partial = await this.finalizeOutput(output);
443
- throw new BashInterruptedError("aborted", "Command aborted by user", partial, error);
513
+ throw new BashInterruptedError(
514
+ "aborted",
515
+ "Command aborted by user",
516
+ partial,
517
+ sandboxHint,
518
+ error,
519
+ );
444
520
  }
445
521
  throw error;
446
522
  } finally {
@@ -10,7 +10,12 @@ import {
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import { Type } from "typebox";
12
12
 
13
- import { BashInterruptedError, type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
13
+ import {
14
+ appendSandboxHint,
15
+ BashInterruptedError,
16
+ type BwrapRuntime,
17
+ createBwrapRuntime,
18
+ } from "../bwrap/runtime.js";
14
19
  import { resolveWorkdir } from "../lib/path.js";
15
20
 
16
21
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -151,10 +156,10 @@ export function registerShellTools(
151
156
  const full = text ? `${text}\n\nCommand aborted by user` : "Command aborted by user";
152
157
  return { content: [{ type: "text", text: full }], details: undefined };
153
158
  }
154
- const full = text
159
+ const status = text
155
160
  ? `${text}\n\nCommand timed out after ${timeout} milliseconds`
156
161
  : `Command timed out after ${timeout} milliseconds`;
157
- throw new Error(full, { cause: error });
162
+ throw new Error(appendSandboxHint(status, error.sandboxHint), { cause: error });
158
163
  }
159
164
  throw error;
160
165
  }
@@ -165,9 +170,11 @@ export function registerShellTools(
165
170
  const full = result.fullOutputPath
166
171
  ? await readFile(result.fullOutputPath, "utf8")
167
172
  : result.output;
168
- throw new Error(formatBashError(result.exitCode, full), {
169
- cause: result,
170
- });
173
+ // 失败时附带沙箱状态:命令可能是被沙箱的写边界或网络限制挡住的
174
+ throw new Error(
175
+ appendSandboxHint(formatBashError(result.exitCode, full), result.sandboxHint),
176
+ { cause: result },
177
+ );
171
178
  }
172
179
  return formatBashSuccess(result);
173
180
  },
@@ -95,7 +95,7 @@ export function isGhAvailable(): boolean {
95
95
  return false;
96
96
  }
97
97
 
98
- export async function runGh(
98
+ export function runGh(
99
99
  args: string[],
100
100
  ctx: {
101
101
  cwd?: string;
@@ -105,15 +105,13 @@ export async function runGh(
105
105
  env?: NodeJS.ProcessEnv;
106
106
  },
107
107
  ): Promise<GhResult> {
108
- const proxyEnv = await ghProxy.env();
109
-
110
108
  return new Promise((resolve) => {
111
109
  const proc = spawn("gh", args, {
112
110
  cwd: ctx.cwd,
113
111
  shell: false,
114
112
  stdio: ["ignore", "pipe", "pipe"],
115
113
  // gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
116
- env: { ...process.env, ...proxyEnv, ...ctx.env, GH_PAGER: "cat" },
114
+ env: { ...process.env, ...ghProxy.env, ...ctx.env, GH_PAGER: "cat" },
117
115
  });
118
116
 
119
117
  let stdout = "";
@@ -349,16 +347,14 @@ function toToolResult(
349
347
  * tool's id parameter, e.g. `repo=x/y number=123`. Returns undefined when
350
348
  * neither is available, so the pendant is omitted rather than shown empty.
351
349
  */
352
- function subtitlePendant(
353
- params: { repo?: string } & Record<string, unknown>,
354
- idKey?: string,
350
+ function subtitlePendant<IdKey extends string = never>(
351
+ params: { repo?: string } & Partial<Record<IdKey, string | number>>,
352
+ idKey?: IdKey,
355
353
  ): ToolPendant | undefined {
356
354
  const parts: string[] = [];
357
355
  if (params.repo) parts.push(`repo=${params.repo}`);
358
- if (idKey) {
359
- const id = params[idKey];
360
- if (typeof id === "string" || typeof id === "number") parts.push(`${idKey}=${id}`);
361
- }
356
+ const id = idKey === undefined ? undefined : params[idKey];
357
+ if (typeof id === "string" || typeof id === "number") parts.push(`${idKey}=${id}`);
362
358
  if (parts.length === 0) return undefined;
363
359
  return { subtitle: parts.join(" ") };
364
360
  }
@@ -756,7 +752,7 @@ export function extractStepFromLog(
756
752
 
757
753
  // ── ci-logs rendering (pure, testable) ──────────────────────────────────────
758
754
 
759
- export interface CiLogsResult {
755
+ export interface ToolResult {
760
756
  content: { type: "text"; text: string }[];
761
757
  details: Record<string, unknown>;
762
758
  }
@@ -978,7 +974,7 @@ export interface PollPrChecksOptions {
978
974
  /** Test overrides. */
979
975
  intervalMs?: number;
980
976
  deadlineMs?: number;
981
- onUpdate?: (msg: CiLogsResult) => void;
977
+ onUpdate?: (msg: ToolResult) => void;
982
978
  }
983
979
 
984
980
  /**
@@ -1136,45 +1132,243 @@ export function renderChecksVerdict(options: {
1136
1132
  };
1137
1133
  }
1138
1134
 
1139
- // ── tools ────────────────────────────────────────────────────────────────────
1135
+ // ── GitHub REST client ───────────────────────────────────────────────────────
1140
1136
 
1141
- export default function ghReadonlyTools(pi: ExtensionAPI) {
1142
- // Windows 上禁用:gh 可执行文件的探测(无扩展名 + POSIX 路径)与进程
1143
- // 管理(SIGTERM 信号语义)都是 POSIX 假设,不做 Windows 适配。
1144
- if (process.platform === "win32") {
1145
- pi.on("session_start", (_event, ctx) => {
1146
- ctx.ui.notify("gh-readonly tools are disabled on Windows.", "warning");
1147
- });
1148
- return;
1137
+ /** Toolcall input for the handlers that read through the REST API. */
1138
+ interface PrStatusParams {
1139
+ number: number | string;
1140
+ repo?: string;
1141
+ }
1142
+
1143
+ interface RunIdParams {
1144
+ run_id: number | string;
1145
+ repo?: string;
1146
+ }
1147
+
1148
+ interface JobIdParams {
1149
+ job_id: number | string;
1150
+ repo?: string;
1151
+ }
1152
+
1153
+ interface PrChecksWaitParams {
1154
+ number: number | string;
1155
+ repo?: string;
1156
+ fail_fast?: boolean;
1157
+ }
1158
+
1159
+ interface CommitChecksWaitParams {
1160
+ commit: number | string;
1161
+ repo?: string;
1162
+ event?: string;
1163
+ fail_fast?: boolean;
1164
+ }
1165
+
1166
+ /** What a toolcall handler receives from the framework. */
1167
+ export interface ToolCall<Params> {
1168
+ params: Params;
1169
+ ctx: { cwd?: string };
1170
+ signal?: AbortSignal;
1171
+ /** Streaming progress updates, passed through as-is. */
1172
+ onUpdate?: (update: ToolResult) => void;
1173
+ }
1174
+
1175
+ /**
1176
+ * The GitHub reads that go through the REST API, with the HTTP layer injected:
1177
+ * production hands in the proxy-aware fetch, tests hand in a stub and never
1178
+ * touch the network. Handlers that only shell out to `gh` stay plain functions.
1179
+ *
1180
+ * `fetch` is a property (not module state) so a caller that needs different HTTP
1181
+ * behavior — a test, another host — constructs its own instance.
1182
+ */
1183
+ export class GhClient {
1184
+ readonly fetch: typeof globalThis.fetch;
1185
+ private readonly search: GithubSearch;
1186
+ private readonly checks: GithubChecksClient;
1187
+
1188
+ constructor(fetchImpl: typeof globalThis.fetch = ghProxy.fetch) {
1189
+ this.fetch = fetchImpl;
1190
+ this.search = createGithubSearch({ fetch: fetchImpl });
1191
+ this.checks = createGithubChecks({ fetch: fetchImpl });
1149
1192
  }
1150
1193
 
1151
- // Fail fast: the `gh` CLI is the only backend for these tools. Without it the
1152
- // extension registers nothing and reports the problem at session start, so
1153
- // the user gets one clear error instead of a dozen failing tool calls.
1154
- if (!isGhAvailable()) {
1155
- pi.on("session_start", (_event, ctx) => {
1156
- ctx.ui.notify(
1157
- "gh CLI not found in PATH: GitHub read-only tools are disabled. Install GitHub CLI (https://cli.github.com/) and reload the session.",
1158
- "error",
1159
- );
1160
- });
1161
- return;
1194
+ /** `list-github-issues` / `list-github-prs`: browse through `gh`, search through the API. */
1195
+ private async list(kind: "issue" | "pr", call: ToolCall<ListFilters>): Promise<ToolResult> {
1196
+ const { params, ctx, signal } = call;
1197
+ const result = toToolResult(
1198
+ params.keywords
1199
+ ? await searchList(kind, params, this.search)
1200
+ : await listGithub(kind, params, { cwd: ctx.cwd, signal, input: params }),
1201
+ params,
1202
+ );
1203
+ result.details.pendant = subtitlePendant(params);
1204
+ return result;
1205
+ }
1206
+
1207
+ listIssues(call: ToolCall<ListFilters>): Promise<ToolResult> {
1208
+ return this.list("issue", call);
1162
1209
  }
1163
1210
 
1164
- pi.on("session_start", async (_event, ctx) => {
1165
- // 代理配置读不出来(JSON 语法错、字段类型错、proxy 不是 http(s) URL)时只告警,
1166
- // 工具按直连继续工作——配置写错不该让整套 GitHub 工具不可用。
1167
- const { error } = await ghProxy.load();
1168
- if (!error) return;
1211
+ listPrs(call: ToolCall<ListFilters>): Promise<ToolResult> {
1212
+ return this.list("pr", call);
1213
+ }
1214
+
1215
+ /**
1216
+ * `read-github-pr-status`: the PR head commit's checks as a snapshot. Same read
1217
+ * path as the wait tools (octokit), but it never polls — pending checks come
1218
+ * back as-is.
1219
+ */
1220
+ async prStatus(call: ToolCall<PrStatusParams>): Promise<ToolResult> {
1221
+ const { params, ctx, signal } = call;
1222
+ const { number, repo } = params;
1223
+ const pullNumber = toPositiveId(number, "number");
1224
+ const pendant = subtitlePendant(params, "number");
1225
+ const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1226
+ const { owner, repo: name } = splitRepo(effectiveRepo);
1227
+
1228
+ const pollSignal = signal ?? new AbortController().signal;
1229
+ const headSha = await this.checks.pullHead(owner, name, pullNumber, pollSignal);
1230
+ const [statuses, checkRuns] = await Promise.all([
1231
+ this.checks.statuses(owner, name, headSha, pollSignal),
1232
+ this.checks.checkRuns(owner, name, headSha, pollSignal),
1233
+ ]);
1234
+ const checks = mergeChecks(statuses, checkRuns).map((check) => ({
1235
+ name: check.name,
1236
+ bucket: check.bucket,
1237
+ event: check.event,
1238
+ run_id: check.runId,
1239
+ job_id: check.jobId,
1240
+ url: check.link,
1241
+ }));
1242
+
1243
+ const payload = { pr: pullNumber, repo: effectiveRepo, head_sha: headSha, checks };
1244
+ return {
1245
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1246
+ details: { ...payload, input: params, ...(pendant && { pendant }) },
1247
+ };
1248
+ }
1249
+
1250
+ /** `get-github-workflow-jobs`: every job of a run, all pages. */
1251
+ async workflowJobs(call: ToolCall<RunIdParams>): Promise<ToolResult> {
1252
+ const { params, ctx, signal } = call;
1253
+ const runId = toPositiveId(params.run_id, "run_id");
1254
+ const effectiveRepo = await resolveRepo(params.repo, signal, ctx.cwd, params);
1255
+ const { owner, repo: name } = splitRepo(effectiveRepo);
1256
+
1257
+ const jobs = await this.checks.runJobs(owner, name, runId, signal);
1258
+ const result = toToolResult(JSON.stringify({ total_count: jobs.length, jobs }), params);
1259
+ result.details.pendant = subtitlePendant(params, "run_id");
1260
+ return result;
1261
+ }
1262
+
1263
+ /** `read-github-ci-logs`: one job's raw log on disk plus its step line ranges. */
1264
+ async ciLogs(call: ToolCall<JobIdParams>): Promise<ToolResult> {
1265
+ const { params, ctx, signal, onUpdate } = call;
1266
+ const { job_id, repo } = params;
1267
+ const jobId = toPositiveId(job_id, "job_id");
1268
+
1269
+ const pendant = subtitlePendant(params, "job_id");
1270
+ const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1271
+ const { owner, repo: name } = splitRepo(effectiveRepo);
1272
+
1273
+ const failure = (text: string): ToolResult => ({
1274
+ content: [{ type: "text", text }],
1275
+ details: { input: params, ...(pendant && { pendant }) },
1276
+ });
1277
+
1278
+ let target: RunJob;
1169
1279
  try {
1170
- ctx.ui.notify(`gh proxy config ignored: ${error}`, "warning");
1171
- } catch {
1172
- // 读取期间 session 可能已被替换,失效的 ctx 直接忽略
1280
+ target = await this.checks.job(owner, name, jobId, signal);
1281
+ } catch (error) {
1282
+ const status = (error as { status?: number }).status;
1283
+ if (status !== 404) throw error;
1284
+ return failure(
1285
+ `Job ${jobId} not found in ${effectiveRepo} — job IDs come from \`get-github-workflow-jobs\`.`,
1286
+ );
1173
1287
  }
1174
- });
1175
1288
 
1176
- const githubSearch = createGithubSearch({ fetch: ghProxy.fetch });
1177
- const githubChecks = createGithubChecks({ fetch: ghProxy.fetch });
1289
+ if (target.status === "queued") {
1290
+ return failure(
1291
+ `Job "${target.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
1292
+ );
1293
+ }
1294
+
1295
+ onUpdate?.({
1296
+ content: [{ type: "text", text: `Fetching log of job "${target.name}"...` }],
1297
+ details: {},
1298
+ });
1299
+
1300
+ const rawLog = await getJobLog(target, signal, ctx.cwd, params);
1301
+ const index = jobLogIndex(target, rawLog);
1302
+
1303
+ return {
1304
+ content: [{ type: "text", text: JSON.stringify(index, null, 2) }],
1305
+ details: { ...index, input: params, ...(pendant && { pendant }) },
1306
+ };
1307
+ }
1308
+
1309
+ /** `wait-github-pr-checks`: poll the PR's head commit checks until they settle. */
1310
+ async waitPrChecks(call: ToolCall<PrChecksWaitParams>): Promise<ToolResult> {
1311
+ const { params, ctx, signal, onUpdate } = call;
1312
+ const { number, repo, fail_fast } = params;
1313
+
1314
+ const pendant = subtitlePendant(params, "number");
1315
+ onUpdate?.({
1316
+ content: [{ type: "text", text: `Watching CI checks for PR #${number}...` }],
1317
+ details: {},
1318
+ });
1319
+
1320
+ const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1321
+ const { owner, repo: repoName } = splitRepo(effectiveRepo);
1322
+
1323
+ const prOut = await ghExec(
1324
+ ["pr", "view", String(number), "--repo", effectiveRepo, "--json", "headRefOid"],
1325
+ { cwd: ctx.cwd, signal, input: params },
1326
+ );
1327
+ const { headRefOid } = Value.Parse(prHeadSchema, JSON.parse(prOut));
1328
+
1329
+ return this.waitChecksReport({
1330
+ subject: `PR #${number}`,
1331
+ owner,
1332
+ repo: repoName,
1333
+ headSha: headRefOid,
1334
+ failFast: fail_fast === true,
1335
+ signal,
1336
+ onUpdate,
1337
+ params,
1338
+ pendant,
1339
+ });
1340
+ }
1341
+
1342
+ /** `wait-github-commit-checks`: same, addressed by commit/branch/tag instead of a PR. */
1343
+ async waitCommitChecks(call: ToolCall<CommitChecksWaitParams>): Promise<ToolResult> {
1344
+ const { params, ctx, signal, onUpdate } = call;
1345
+ const { commit, repo, event, fail_fast } = params;
1346
+
1347
+ const pendant = subtitlePendant(params, "commit");
1348
+ onUpdate?.({
1349
+ content: [{ type: "text", text: `Watching CI checks for commit ${commit}...` }],
1350
+ details: {},
1351
+ });
1352
+
1353
+ const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1354
+ const { owner, repo: repoName } = splitRepo(effectiveRepo);
1355
+
1356
+ const pollSignal = signal ?? new AbortController().signal;
1357
+ const sha = await this.checks.headSha(owner, repoName, String(commit), pollSignal);
1358
+
1359
+ return this.waitChecksReport({
1360
+ subject: `commit ${sha.slice(0, 7)}`,
1361
+ owner,
1362
+ repo: repoName,
1363
+ headSha: sha,
1364
+ failFast: fail_fast === true,
1365
+ event,
1366
+ signal,
1367
+ onUpdate,
1368
+ params,
1369
+ pendant,
1370
+ });
1371
+ }
1178
1372
 
1179
1373
  /**
1180
1374
  * Shared wait core of `wait-github-pr-checks` and
@@ -1182,7 +1376,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1182
1376
  * reports with Actions job details (display-only), render the verdict.
1183
1377
  * The caller resolves repo/headSha; `subject` formats the report header.
1184
1378
  */
1185
- async function waitChecksReport(options: {
1379
+ private async waitChecksReport(options: {
1186
1380
  subject: string;
1187
1381
  owner: string;
1188
1382
  repo: string;
@@ -1190,10 +1384,10 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1190
1384
  failFast: boolean;
1191
1385
  event?: string;
1192
1386
  signal: AbortSignal | undefined;
1193
- onUpdate: ((msg: CiLogsResult) => void) | undefined;
1387
+ onUpdate: ((msg: ToolResult) => void) | undefined;
1194
1388
  params: unknown;
1195
1389
  pendant?: ToolPendant;
1196
- }) {
1390
+ }): Promise<ToolResult> {
1197
1391
  const { subject, owner, repo, headSha, failFast, event, signal, onUpdate, params, pendant } =
1198
1392
  options;
1199
1393
 
@@ -1207,7 +1401,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1207
1401
  headSha,
1208
1402
  failFast,
1209
1403
  event,
1210
- checks: githubChecks,
1404
+ checks: this.checks,
1211
1405
  signal: pollSignal,
1212
1406
  onUpdate,
1213
1407
  });
@@ -1218,7 +1412,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1218
1412
  let enrichmentError: string | undefined;
1219
1413
  if (poll.checks.some((c) => c.bucket === "fail")) {
1220
1414
  try {
1221
- actionJobs = await githubChecks.actionJobs(owner, repo, headSha, pollSignal);
1415
+ actionJobs = await this.checks.actionJobs(owner, repo, headSha, pollSignal);
1222
1416
  } catch (error) {
1223
1417
  enrichmentError =
1224
1418
  error instanceof Error ? error.message : "Actions job details unavailable";
@@ -1227,7 +1421,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1227
1421
 
1228
1422
  const verdict = renderChecksVerdict({ subject, poll, actionJobs, enrichmentError });
1229
1423
  return {
1230
- content: [{ type: "text" as const, text: verdict.text }],
1424
+ content: [{ type: "text", text: verdict.text }],
1231
1425
  details: {
1232
1426
  status: verdict.status,
1233
1427
  totalChecks: poll.checks.length,
@@ -1238,6 +1432,34 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1238
1432
  },
1239
1433
  };
1240
1434
  }
1435
+ }
1436
+
1437
+ // ── tools ────────────────────────────────────────────────────────────────────
1438
+
1439
+ export default function ghReadonlyTools(pi: ExtensionAPI) {
1440
+ // Windows 上禁用:gh 可执行文件的探测(无扩展名 + POSIX 路径)与进程
1441
+ // 管理(SIGTERM 信号语义)都是 POSIX 假设,不做 Windows 适配。
1442
+ if (process.platform === "win32") {
1443
+ pi.on("session_start", (_event, ctx) => {
1444
+ ctx.ui.notify("gh-readonly tools are disabled on Windows.", "warning");
1445
+ });
1446
+ return;
1447
+ }
1448
+
1449
+ // Fail fast: the `gh` CLI is the only backend for these tools. Without it the
1450
+ // extension registers nothing and reports the problem at session start, so
1451
+ // the user gets one clear error instead of a dozen failing tool calls.
1452
+ if (!isGhAvailable()) {
1453
+ pi.on("session_start", (_event, ctx) => {
1454
+ ctx.ui.notify(
1455
+ "gh CLI not found in PATH: GitHub read-only tools are disabled. Install GitHub CLI (https://cli.github.com/) and reload the session.",
1456
+ "error",
1457
+ );
1458
+ });
1459
+ return;
1460
+ }
1461
+
1462
+ const client = new GhClient();
1241
1463
 
1242
1464
  // ── read-github-issue ──────────────────────────────────────────────────────
1243
1465
  pi.registerTool({
@@ -1300,15 +1522,8 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1300
1522
  }),
1301
1523
  ),
1302
1524
  }),
1303
- async execute(_id, params, signal, _onUpdate, ctx) {
1304
- const result = toToolResult(
1305
- params.keywords
1306
- ? await searchList("issue", params, githubSearch)
1307
- : await listGithub("issue", params, { cwd: ctx.cwd, signal, input: params }),
1308
- params,
1309
- );
1310
- result.details.pendant = subtitlePendant(params);
1311
- return result;
1525
+ async execute(_id, params, signal, onUpdate, ctx) {
1526
+ return client.listIssues({ params, ctx, signal, onUpdate });
1312
1527
  },
1313
1528
  });
1314
1529
 
@@ -1373,15 +1588,8 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1373
1588
  }),
1374
1589
  ),
1375
1590
  }),
1376
- async execute(_id, params, signal, _onUpdate, ctx) {
1377
- const result = toToolResult(
1378
- params.keywords
1379
- ? await searchList("pr", params, githubSearch)
1380
- : await listGithub("pr", params, { cwd: ctx.cwd, signal, input: params }),
1381
- params,
1382
- );
1383
- result.details.pendant = subtitlePendant(params);
1384
- return result;
1591
+ async execute(_id, params, signal, onUpdate, ctx) {
1592
+ return client.listPrs({ params, ctx, signal, onUpdate });
1385
1593
  },
1386
1594
  });
1387
1595
 
@@ -1418,35 +1626,8 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1418
1626
  number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
1419
1627
  repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
1420
1628
  }),
1421
- async execute(_id, params, signal, _onUpdate, ctx) {
1422
- const { number, repo } = params;
1423
- const pullNumber = toPositiveId(number, "number");
1424
- const pendant = subtitlePendant(params, "number");
1425
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1426
- const { owner, repo: name } = splitRepo(effectiveRepo);
1427
-
1428
- // 与 wait 工具同一条读取路径(octokit),只是这里不轮询:pending 的 check
1429
- // 原样返回,等结果走 wait-github-pr-checks。
1430
- const pollSignal = signal ?? new AbortController().signal;
1431
- const headSha = await githubChecks.pullHead(owner, name, pullNumber, pollSignal);
1432
- const [statuses, checkRuns] = await Promise.all([
1433
- githubChecks.statuses(owner, name, headSha, pollSignal),
1434
- githubChecks.checkRuns(owner, name, headSha, pollSignal),
1435
- ]);
1436
- const checks = mergeChecks(statuses, checkRuns).map((check) => ({
1437
- name: check.name,
1438
- bucket: check.bucket,
1439
- event: check.event,
1440
- run_id: check.runId,
1441
- job_id: check.jobId,
1442
- url: check.link,
1443
- }));
1444
-
1445
- const payload = { pr: pullNumber, repo: effectiveRepo, head_sha: headSha, checks };
1446
- return {
1447
- content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
1448
- details: { ...payload, input: params, ...(pendant && { pendant }) },
1449
- };
1629
+ async execute(_id, params, signal, onUpdate, ctx) {
1630
+ return client.prStatus({ params, ctx, signal, onUpdate });
1450
1631
  },
1451
1632
  });
1452
1633
 
@@ -1582,47 +1763,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1582
1763
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1583
1764
  }),
1584
1765
  async execute(_id, params, signal, onUpdate, ctx) {
1585
- const { job_id, repo } = params;
1586
- const jobId = toPositiveId(job_id, "job_id");
1587
-
1588
- const pendant = subtitlePendant(params, "job_id");
1589
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1590
- const { owner, repo: name } = splitRepo(effectiveRepo);
1591
-
1592
- const failure = (text: string): CiLogsResult => ({
1593
- content: [{ type: "text", text }],
1594
- details: { input: params, ...(pendant && { pendant }) },
1595
- });
1596
-
1597
- let target: RunJob;
1598
- try {
1599
- target = await githubChecks.job(owner, name, jobId, signal);
1600
- } catch (error) {
1601
- const status = (error as { status?: number }).status;
1602
- if (status !== 404) throw error;
1603
- return failure(
1604
- `Job ${jobId} not found in ${effectiveRepo} — job IDs come from \`get-github-workflow-jobs\`.`,
1605
- );
1606
- }
1607
-
1608
- if (target.status === "queued") {
1609
- return failure(
1610
- `Job "${target.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
1611
- );
1612
- }
1613
-
1614
- onUpdate?.({
1615
- content: [{ type: "text", text: `Fetching log of job "${target.name}"...` }],
1616
- details: {},
1617
- });
1618
-
1619
- const rawLog = await getJobLog(target, signal, ctx.cwd, params);
1620
- const index = jobLogIndex(target, rawLog);
1621
-
1622
- return {
1623
- content: [{ type: "text", text: JSON.stringify(index, null, 2) }],
1624
- details: { ...index, input: params, ...(pendant && { pendant }) },
1625
- };
1766
+ return client.ciLogs({ params, ctx, signal, onUpdate });
1626
1767
  },
1627
1768
  });
1628
1769
 
@@ -1637,15 +1778,8 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1637
1778
  run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
1638
1779
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1639
1780
  }),
1640
- async execute(_id, params, signal, _onUpdate, ctx) {
1641
- const { run_id, repo } = params;
1642
- const runId = toPositiveId(run_id, "run_id");
1643
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1644
- const { owner, repo: name } = splitRepo(effectiveRepo);
1645
- const jobs = await githubChecks.runJobs(owner, name, runId, signal);
1646
- const result = toToolResult(JSON.stringify({ total_count: jobs.length, jobs }), params);
1647
- result.details.pendant = subtitlePendant(params, "run_id");
1648
- return result;
1781
+ async execute(_id, params, signal, onUpdate, ctx) {
1782
+ return client.workflowJobs({ params, ctx, signal, onUpdate });
1649
1783
  },
1650
1784
  });
1651
1785
 
@@ -1738,34 +1872,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1738
1872
  ),
1739
1873
  }),
1740
1874
  async execute(_id, params, signal, onUpdate, ctx) {
1741
- const { number, repo, fail_fast } = params;
1742
-
1743
- const pendant = subtitlePendant(params, "number");
1744
- onUpdate?.({
1745
- content: [{ type: "text", text: `Watching CI checks for PR #${number}...` }],
1746
- details: {},
1747
- });
1748
-
1749
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1750
- const { owner, repo: repoName } = splitRepo(effectiveRepo);
1751
-
1752
- const prOut = await ghExec(
1753
- ["pr", "view", String(number), "--repo", effectiveRepo, "--json", "headRefOid"],
1754
- { cwd: ctx.cwd, signal, input: params },
1755
- );
1756
- const { headRefOid } = Value.Parse(prHeadSchema, JSON.parse(prOut));
1757
-
1758
- return waitChecksReport({
1759
- subject: `PR #${number}`,
1760
- owner,
1761
- repo: repoName,
1762
- headSha: headRefOid,
1763
- failFast: fail_fast === true,
1764
- signal,
1765
- onUpdate,
1766
- params,
1767
- pendant,
1768
- });
1875
+ return client.waitPrChecks({ params, ctx, signal, onUpdate });
1769
1876
  },
1770
1877
  });
1771
1878
 
@@ -1798,32 +1905,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1798
1905
  ),
1799
1906
  }),
1800
1907
  async execute(_id, params, signal, onUpdate, ctx) {
1801
- const { commit, repo, event, fail_fast } = params;
1802
-
1803
- const pendant = subtitlePendant(params, "commit");
1804
- onUpdate?.({
1805
- content: [{ type: "text", text: `Watching CI checks for commit ${commit}...` }],
1806
- details: {},
1807
- });
1808
-
1809
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1810
- const { owner, repo: repoName } = splitRepo(effectiveRepo);
1811
-
1812
- const pollSignal = signal ?? new AbortController().signal;
1813
- const sha = await githubChecks.headSha(owner, repoName, String(commit), pollSignal);
1814
-
1815
- return waitChecksReport({
1816
- subject: `commit ${sha.slice(0, 7)}`,
1817
- owner,
1818
- repo: repoName,
1819
- headSha: sha,
1820
- failFast: fail_fast === true,
1821
- event,
1822
- signal,
1823
- onUpdate,
1824
- params,
1825
- pendant,
1826
- });
1908
+ return client.waitCommitChecks({ params, ctx, signal, onUpdate });
1827
1909
  },
1828
1910
  });
1829
1911
 
@@ -5,12 +5,15 @@
5
5
  * - ~/.pi/agent/gh.json: { "proxy": "http://127.0.0.1:7890", "noProxy": "localhost,.corp" }
6
6
  * - HTTPS_PROXY / HTTP_PROXY / ALL_PROXY(小写变体同样接受)、NO_PROXY
7
7
  *
8
- * 两条出口共用同一份配置:
9
- * - gh CLI 子进程:env() 给出要注入子进程的 HTTP(S)_PROXY / NO_PROXY 等变量
8
+ * 两条出口共用同一份配置,且在扩展加载时一次性读完:
9
+ * - gh CLI 子进程:env 给出要注入子进程的 HTTP(S)_PROXY / NO_PROXY 等变量
10
10
  * - octokit 请求:fetch 是挂了代理 dispatcher 的 fetch;未配置代理时就是全局 fetch
11
+ *
12
+ * 配置有错(JSON 语法错、字段类型不符、proxy 不是 http(s) URL)直接抛错——扩展
13
+ * 加载即失败,而不是带着一份被忽略的配置静默直连。
11
14
  */
12
15
 
13
- import { readFile } from "node:fs/promises";
16
+ import { readFileSync } from "node:fs";
14
17
  import { homedir } from "node:os";
15
18
  import { join } from "node:path";
16
19
 
@@ -63,54 +66,55 @@ function firstEnv(names: readonly string[], env: NodeJS.ProcessEnv): string | un
63
66
  }
64
67
 
65
68
  /** 代理必须是 http(s) URL:undici 的 ProxyAgent 只支持 HTTP CONNECT 代理。 */
66
- function normalizeProxy(value: string | undefined): { proxy?: string; error?: string } {
67
- if (!value) return {};
69
+ function normalizeProxy(value: string): string {
68
70
  let protocol: string;
69
71
  try {
70
72
  protocol = new URL(value).protocol;
71
73
  } catch {
72
- return { error: `invalid proxy URL: ${value}` };
74
+ throw new Error(`invalid proxy URL: ${value}`);
73
75
  }
74
76
  if (protocol !== "http:" && protocol !== "https:") {
75
- return { error: `unsupported proxy protocol: ${value} (expected http:// or https://)` };
77
+ throw new Error(`unsupported proxy protocol: ${value} (expected http:// or https://)`);
76
78
  }
77
- return { proxy: value };
78
- }
79
-
80
- export interface GhProxyLoad {
81
- /** 生效的代理设置(配置文件与环境变量合并后的结果)。 */
82
- settings: GhProxySettings;
83
- /** 配置读取/解析失败的原因;未失败时为 undefined。 */
84
- error?: string;
85
- }
86
-
87
- function describeError(error: unknown): string {
88
- return error instanceof Error ? error.message : String(error);
79
+ return value;
89
80
  }
90
81
 
91
- /** 读配置文件;文件不存在视为未配置,其它失败只记录不抛出。 */
92
- async function readConfigFile(configPath: string): Promise<GhProxyLoad> {
93
- let raw: string;
82
+ /**
83
+ * 同步读配置:扩展加载时调用一次(node:fs/promises 在同步的初始化路径上用不了,
84
+ * 这里是仓库里允许的同步例外)。文件不存在 = 未配置;文件读不了、JSON 非法或
85
+ * 字段不符都直接抛。
86
+ */
87
+ export function readGhProxySettings(
88
+ configPath: string = ghProxyConfigPath(),
89
+ env: NodeJS.ProcessEnv = process.env,
90
+ ): GhProxySettings {
91
+ let raw: string | undefined;
94
92
  try {
95
- raw = await readFile(configPath, "utf8");
93
+ raw = readFileSync(configPath, "utf8");
96
94
  } catch (error) {
97
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return { settings: {} };
98
- return { settings: {}, error: `${configPath}: ${describeError(error)}` };
95
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
96
+ throw new Error(`${configPath}: ${error instanceof Error ? error.message : String(error)}`, {
97
+ cause: error,
98
+ });
99
+ }
99
100
  }
100
- try {
101
- return { settings: parseGhProxyConfig(JSON.parse(raw)) };
102
- } catch (error) {
103
- return { settings: {}, error: `${configPath}: ${describeError(error)}` };
101
+
102
+ let file: GhProxySettings = {};
103
+ if (raw !== undefined) {
104
+ try {
105
+ file = parseGhProxyConfig(JSON.parse(raw));
106
+ } catch (error) {
107
+ throw new Error(`${configPath}: ${error instanceof Error ? error.message : String(error)}`, {
108
+ cause: error,
109
+ });
110
+ }
104
111
  }
105
- }
106
112
 
107
- async function resolveSettings(configPath: string, env: NodeJS.ProcessEnv): Promise<GhProxyLoad> {
108
- const file = await readConfigFile(configPath);
109
- const normalized = normalizeProxy(file.settings.proxy ?? firstEnv(PROXY_ENV_NAMES, env));
110
- const noProxy = file.settings.noProxy ?? firstEnv(NO_PROXY_ENV_NAMES, env);
113
+ const proxy = file.proxy ?? firstEnv(PROXY_ENV_NAMES, env);
114
+ const noProxy = file.noProxy ?? firstEnv(NO_PROXY_ENV_NAMES, env);
111
115
  return {
112
- settings: { ...(normalized.proxy && { proxy: normalized.proxy }), ...(noProxy && { noProxy }) },
113
- error: file.error ?? normalized.error,
116
+ ...(proxy && { proxy: normalizeProxy(proxy) }),
117
+ ...(noProxy && { noProxy }),
114
118
  };
115
119
  }
116
120
 
@@ -151,46 +155,32 @@ function createProxyDispatcher(
151
155
  }
152
156
 
153
157
  export interface GhProxy {
154
- /** 读取配置(首个调用触发读盘,之后返回同一个缓存结果,失败不抛出)。 */
155
- load(): Promise<GhProxyLoad>;
158
+ /** 生效的代理设置(配置文件与环境变量合并后的结果)。 */
159
+ readonly settings: GhProxySettings;
156
160
  /** 要注入 gh 子进程的代理环境变量;未配置代理时为空对象。 */
157
- env(): Promise<NodeJS.ProcessEnv>;
161
+ readonly env: NodeJS.ProcessEnv;
158
162
  /** 走代理的 fetch;未配置代理时就是全局 fetch。 */
159
163
  readonly fetch: typeof globalThis.fetch;
160
164
  }
161
165
 
162
166
  /**
163
- * 创建代理配置读取器。配置只在首次使用时读一次并缓存;`load` 与 `fetch` 共用这次
164
- * 读取,因此运行期不会出现两者看到不同配置的情况。
165
- *
166
- * 缓存的是 dispatcher(连接池复用),**不是** `globalThis.fetch` 本身:每次调用都
167
- * 取当前的全局 fetch,否则首个请求之后替换 `globalThis.fetch`(插桩、测试替身)
168
- * 就不再生效。
167
+ * 读一次配置并组装代理层。缓存的是 dispatcher(连接池复用),**不是**
168
+ * `globalThis.fetch` 本身:每次调用都取当前的全局 fetch,否则首个请求之后替换
169
+ * `globalThis.fetch`(插桩、测试替身)就不再生效。
169
170
  */
170
171
  export function createGhProxy(
171
172
  configPath: string = ghProxyConfigPath(),
172
173
  env: NodeJS.ProcessEnv = process.env,
173
174
  ): GhProxy {
174
- let loading: Promise<GhProxyLoad> | undefined;
175
+ const settings = readGhProxySettings(configPath, env);
176
+ const { proxy, noProxy } = settings;
175
177
  let dispatcher: NonNullable<RequestInit["dispatcher"]> | undefined;
176
178
 
177
- function load(): Promise<GhProxyLoad> {
178
- loading ??= resolveSettings(configPath, env);
179
- return loading;
180
- }
181
-
182
- return {
183
- load,
184
- env: async () => {
185
- const { settings } = await load();
186
- return proxyEnvVars(settings);
187
- },
188
- fetch: async (input, init) => {
189
- const { settings } = await load();
190
- const { proxy, noProxy } = settings;
191
- if (!proxy) return globalThis.fetch(input, init);
192
- dispatcher ??= createProxyDispatcher(proxy, noProxy);
193
- return globalThis.fetch(input, { ...init, dispatcher });
194
- },
179
+ const fetch: typeof globalThis.fetch = (input, init) => {
180
+ if (!proxy) return globalThis.fetch(input, init);
181
+ dispatcher ??= createProxyDispatcher(proxy, noProxy);
182
+ return globalThis.fetch(input, { ...init, dispatcher });
195
183
  };
184
+
185
+ return { settings, env: proxyEnvVars(settings), fetch };
196
186
  }
@@ -4,7 +4,12 @@ import { fileURLToPath } from "node:url";
4
4
  import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
6
 
7
- import { BashInterruptedError, type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
7
+ import {
8
+ appendSandboxHint,
9
+ BashInterruptedError,
10
+ type BwrapRuntime,
11
+ createBwrapRuntime,
12
+ } from "../bwrap/runtime.js";
8
13
  import { resolveWorkdir } from "../lib/path.js";
9
14
 
10
15
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -106,9 +111,13 @@ export default function opencodeBash(
106
111
  error.partial.truncation,
107
112
  error.partial.fullOutputPath,
108
113
  );
114
+ // 超时附带沙箱状态(可能是网络限制让命令挂住);中断是用户主动取消,与沙箱无关
109
115
  const status =
110
116
  error.kind === "timeout"
111
- ? `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
117
+ ? appendSandboxHint(
118
+ `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
119
+ error.sandboxHint,
120
+ )
112
121
  : "Command aborted by user";
113
122
  const full = text ? `${text}\n\n${status}` : status;
114
123
  // 对齐上游 opencode:超时与中断都不抛错,输出与状态文本一起返回
@@ -123,10 +132,16 @@ export default function opencodeBash(
123
132
  // 命令失败(非 0 退出码)不抛错:输出与状态文本一起返回
124
133
  let text = result.output || "(no output)";
125
134
  text = appendTruncationNotice(text, result.truncation, result.fullOutputPath);
135
+ const failed = result.exitCode !== 0 && result.exitCode !== null;
136
+ // 失败时附带沙箱状态:命令可能是被沙箱的写边界或网络限制挡住的
137
+ const status = appendSandboxHint(
138
+ `Command exited with code ${result.exitCode}.`,
139
+ failed ? result.sandboxHint : undefined,
140
+ );
126
141
  return {
127
142
  content: [
128
143
  { type: "text" as const, text },
129
- { type: "text" as const, text: `Command exited with code ${result.exitCode}.` },
144
+ { type: "text" as const, text: status },
130
145
  ],
131
146
  details: {
132
147
  exitCode: result.exitCode,