@trim21/personal-pi-extensions 0.0.432 → 0.0.435

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.432",
3
+ "version": "0.0.435",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/bwrap/core.ts CHANGED
@@ -11,7 +11,12 @@ import { Value } from "typebox/value";
11
11
  import { parseWithSchema } from "../lib/parse-with-schema.js";
12
12
  import { expandHome } from "../lib/path.js";
13
13
  import { type ApprovalRule } from "./approval-rules.js";
14
- import { type NetworkStack, resolveDnsServers, startNetworkStack } from "./network-stack.js";
14
+ import {
15
+ type NetworkStack,
16
+ resolveDnsServers,
17
+ startNetworkStack,
18
+ TimeoutError,
19
+ } from "./network-stack.js";
15
20
 
16
21
  const PROTECTED_DIRS = [".pi", ".agent"];
17
22
 
@@ -399,7 +404,8 @@ export function createBwrapBashOperations(
399
404
  await fsAccess(cwd, constants.F_OK).catch(() => {
400
405
  throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
401
406
  });
402
- if (signal?.aborted) throw new Error("aborted");
407
+ // 已中断(signal.reason 是 name=AbortError 的 DOMException):直接抛,不再执行
408
+ signal?.throwIfAborted();
403
409
 
404
410
  // 干净环境:不继承父进程 env/PATH,由 bash -lc 从 /etc/profile 与用户 profile 重建
405
411
  const home = process.env.HOME;
@@ -470,9 +476,17 @@ export function createBwrapBashOperations(
470
476
  child.once("close", (exitCode) => {
471
477
  if (timeoutHandle) clearTimeout(timeoutHandle);
472
478
  signal?.removeEventListener("abort", onAbort);
473
- if (signal?.aborted) reject(new Error("aborted"));
474
- else if (timedOut) reject(new Error(`timeout:${timeout}`));
475
- else resolve({ exitCode });
479
+ // 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
480
+ if (signal?.aborted) {
481
+ reject(
482
+ signal.reason instanceof Error
483
+ ? signal.reason
484
+ : new Error("The operation was aborted"),
485
+ );
486
+ } else if (timedOut) {
487
+ // 超时:name=TimeoutError(对齐标准错误分类)
488
+ reject(new TimeoutError(timeout));
489
+ } else resolve({ exitCode });
476
490
  });
477
491
  });
478
492
  },
@@ -27,6 +27,8 @@ export interface MihomoConfig {
27
27
  nameserver: string[];
28
28
  "default-nameserver": string[];
29
29
  "fake-ip-filter"?: string[];
30
+ /** 域名级 DNS 规则(mihomo >= 1.18):allowlist 域名 DIRECT,其余 REJECT。 */
31
+ rules: string[];
30
32
  };
31
33
  tun: {
32
34
  enable: true;
@@ -111,6 +113,8 @@ function ipRule(host: string): string {
111
113
  interface BuiltRules {
112
114
  rules: string[];
113
115
  fakeIpFilter: string[];
116
+ /** DNS 层规则:allowlist 域名正常解析,未允许域名直接拒绝(解析失败而非 fake-ip 后连接失败)。 */
117
+ dnsRules: string[];
114
118
  }
115
119
 
116
120
  /**
@@ -119,14 +123,20 @@ interface BuiltRules {
119
123
  * 拿到 fakeip 再进 TUN 形成环(loopback detector 会拒绝);
120
124
  * - 无端口条目直接匹配;带端口条目用 AND 组合(域名/IP + DST-PORT)精确放行;
121
125
  * - 最后以 MATCH,REJECT 兜底实现 deny-by-default。
126
+ * dns.rules 同步构建:allowlist 域名 DIRECT,其余 MATCH,REJECT——未允许域名
127
+ * 在 DNS 层即被拒绝(curl 报 Could not resolve host),而不是先拿 fake-ip、
128
+ * 到连接层才断(报 TLS decode error,容易误判为网络故障)。
122
129
  */
123
130
  function buildRules(allowlist: readonly string[]): BuiltRules {
124
131
  const rules: string[] = [];
125
132
  const fakeIpFilter: string[] = [];
133
+ const dnsRules: string[] = [];
126
134
  for (const entry of allowlist) {
127
135
  const { host, port } = parseAllowlistEntry(entry);
128
136
  if (!isIp(host)) {
129
137
  fakeIpFilter.push(`+.${host}`);
138
+ // DNS 层按域名放行(端口无关);连接层规则保留端口语义
139
+ dnsRules.push(`DOMAIN-SUFFIX,${host},DIRECT`);
130
140
  if (port === undefined) {
131
141
  rules.push(`DOMAIN-SUFFIX,${host},DIRECT`);
132
142
  } else {
@@ -139,7 +149,8 @@ function buildRules(allowlist: readonly string[]): BuiltRules {
139
149
  }
140
150
  }
141
151
  rules.push("MATCH,REJECT");
142
- return { rules, fakeIpFilter };
152
+ dnsRules.push("MATCH,REJECT");
153
+ return { rules, fakeIpFilter, dnsRules };
143
154
  }
144
155
 
145
156
  /**
@@ -154,7 +165,7 @@ export function generateMihomoConfig(options: MihomoConfigOptions): MihomoConfig
154
165
  if (dnsServers.length === 0) {
155
166
  throw new Error("At least one DNS server is required");
156
167
  }
157
- const { rules, fakeIpFilter } = buildRules(allowlist);
168
+ const { rules, fakeIpFilter, dnsRules } = buildRules(allowlist);
158
169
  return {
159
170
  "mixed-port": 0,
160
171
  mode: "rule",
@@ -168,6 +179,7 @@ export function generateMihomoConfig(options: MihomoConfigOptions): MihomoConfig
168
179
  nameserver: [...dnsServers],
169
180
  "default-nameserver": [...dnsServers],
170
181
  ...(fakeIpFilter.length > 0 && { "fake-ip-filter": fakeIpFilter }),
182
+ rules: dnsRules,
171
183
  },
172
184
  tun: {
173
185
  enable: true,
@@ -6,6 +6,14 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  import { generateMihomoConfig } from "./mihomo-config.js";
8
8
 
9
+ /** 命令超时错误:name=TimeoutError(对齐标准错误分类),message 保留 timeout:N 格式。 */
10
+ export class TimeoutError extends Error {
11
+ constructor(timeout: number | undefined) {
12
+ super(`timeout:${timeout}`);
13
+ this.name = "TimeoutError";
14
+ }
15
+ }
16
+
9
17
  export interface NetworkStackOptions {
10
18
  /** 允许直连的域名 / IP:port 列表(每次命令从配置重新读取)。 */
11
19
  readonly allowlist: readonly string[];
@@ -225,9 +233,17 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
225
233
  settled = true;
226
234
  if (timeoutHandle) clearTimeout(timeoutHandle);
227
235
  execOptions.signal?.removeEventListener("abort", onAbort);
228
- if (execOptions.signal?.aborted) reject(new Error("aborted"));
229
- else if (timedOut) reject(new Error(`timeout:${execOptions.timeout}`));
230
- else resolve({ exitCode });
236
+ // 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
237
+ if (execOptions.signal?.aborted) {
238
+ reject(
239
+ execOptions.signal.reason instanceof Error
240
+ ? execOptions.signal.reason
241
+ : new Error("The operation was aborted"),
242
+ );
243
+ } else if (timedOut) {
244
+ // 超时:name=TimeoutError(对齐标准错误分类)
245
+ reject(new TimeoutError(execOptions.timeout));
246
+ } else resolve({ exitCode });
231
247
  });
232
248
  });
233
249
  },
@@ -108,6 +108,35 @@ export interface BwrapExecutionResult {
108
108
  truncation: TruncationResult;
109
109
  }
110
110
 
111
+ /**
112
+ * 超时/中断时命令终止前已捕获的部分输出快照(截断后的文本 + 落盘信息)。
113
+ * 展示格式(输出在前、状态在最后)由上层 Bash 工具按各自风格拼接。
114
+ */
115
+ export interface BashExecutionPartial {
116
+ output: string;
117
+ truncation: TruncationResult;
118
+ fullOutputPath?: string;
119
+ }
120
+
121
+ /** 命令超时或中断(abort signal)时抛出的错误,携带部分输出供上层展示。 */
122
+ export class BashInterruptedError extends Error {
123
+ readonly kind: "timeout" | "aborted";
124
+ readonly partial: BashExecutionPartial;
125
+
126
+ constructor(
127
+ kind: "timeout" | "aborted",
128
+ message: string,
129
+ partial: BashExecutionPartial,
130
+ cause: unknown,
131
+ ) {
132
+ super(message, { cause });
133
+ this.kind = kind;
134
+ this.partial = partial;
135
+ // 对齐标准错误分类:中断=AbortError(用户取消),超时=TimeoutError
136
+ this.name = kind === "aborted" ? "AbortError" : "TimeoutError";
137
+ }
138
+ }
139
+
111
140
  function escapeHtml(text: string): string {
112
141
  return text
113
142
  .replaceAll("&", "&amp;")
@@ -402,35 +431,35 @@ export class BwrapRuntime {
402
431
  signal: request.signal,
403
432
  timeout: request.timeout,
404
433
  });
405
- await output.close();
406
- const truncation = truncateTail(output.tailText());
407
- // 未截断:完整输出已直接返回给模型,临时文件没有用途,删掉避免
408
- // agent-dir/tmp 堆积无主文件(删除失败只残留文件,不影响命令结果)
409
- if (!truncation.truncated && output.filePath) {
410
- try {
411
- await unlink(output.filePath);
412
- } catch {
413
- // 删除失败(如沙箱只读):best-effort,命令结果不受影响
414
- }
415
- output.filePath = undefined;
416
- }
434
+ const partial = await this.finalizeOutput(output);
417
435
  return {
418
436
  exitCode,
419
- output: truncation.content,
420
- ...(output.filePath && { fullOutputPath: output.filePath }),
421
- // 用精确统计值覆盖尾部缓冲的估算(提示文本的行数/字节数要准确)
422
- truncation: { ...truncation, ...output.stats },
437
+ output: partial.output,
438
+ ...(partial.fullOutputPath && { fullOutputPath: partial.fullOutputPath }),
439
+ truncation: partial.truncation,
423
440
  };
424
441
  } catch (error) {
425
- // 底层统一把超时/中断转成可读文案(对齐 pi 内置 bash 工具)
426
- if (error instanceof Error && error.message.startsWith("timeout:")) {
427
- throw new Error(
442
+ // 超时/中断:把命令终止前已捕获的输出附在错误上(文本 + 落盘路径),
443
+ // 展示时输出在前、状态在最后(对齐 pi 内置 bash),避免只报超时丢输出
444
+ // 超时识别:优先 name=TimeoutError(对齐标准错误分类),
445
+ // 兼容 pi local ops 抛的 `timeout:N`(name=Error)
446
+ if (
447
+ error instanceof Error &&
448
+ (error.name === "TimeoutError" || error.message.startsWith("timeout:"))
449
+ ) {
450
+ const partial = await this.finalizeOutput(output);
451
+ throw new BashInterruptedError(
452
+ "timeout",
428
453
  `Command timed out after ${error.message.slice("timeout:".length)} seconds`,
429
- { cause: error },
454
+ partial,
455
+ error,
430
456
  );
431
457
  }
432
- if (error instanceof Error && error.message === "aborted") {
433
- throw new Error("Command aborted", { cause: error });
458
+ // 中断识别:优先 name=AbortError(throwIfAborted/signal.reason),
459
+ // 兼容 pi local ops 抛的 new Error("aborted")
460
+ if (error instanceof Error && (error.name === "AbortError" || error.message === "aborted")) {
461
+ const partial = await this.finalizeOutput(output);
462
+ throw new BashInterruptedError("aborted", "Command aborted", partial, error);
434
463
  }
435
464
  throw error;
436
465
  } finally {
@@ -439,6 +468,28 @@ export class BwrapRuntime {
439
468
  }
440
469
  }
441
470
 
471
+ /** 关闭输出流并返回截断后的快照;未截断时删除临时文件(成功与超时/中断路径共用)。 */
472
+ private async finalizeOutput(output: BashOutput): Promise<BashExecutionPartial> {
473
+ await output.close();
474
+ const truncation = truncateTail(output.tailText());
475
+ // 未截断:完整输出已直接返回给模型,临时文件没有用途,删掉避免
476
+ // agent-dir/tmp 堆积无主文件(删除失败只残留文件,不影响命令结果)
477
+ if (!truncation.truncated && output.filePath) {
478
+ try {
479
+ await unlink(output.filePath);
480
+ } catch {
481
+ // 删除失败(如沙箱只读):best-effort,命令结果不受影响
482
+ }
483
+ output.filePath = undefined;
484
+ }
485
+ return {
486
+ output: truncation.content,
487
+ ...(output.filePath && { fullOutputPath: output.filePath }),
488
+ // 用精确统计值覆盖尾部缓冲的估算(提示文本的行数/字节数要准确)
489
+ truncation: { ...truncation, ...output.stats },
490
+ };
491
+ }
492
+
442
493
  private resolve(ctx: Pick<ExtensionContext, "cwd" | "hasUI">): ResolvedBwrap {
443
494
  const config = loadBwrapConfig(ctx.cwd);
444
495
  if (!ctx.hasUI) return resolveHeadlessBwrap(config);
@@ -53,10 +53,6 @@ export function toRelativePath(filePath: string, cwd: string): string {
53
53
  return relativePath.startsWith("..") ? filePath : relativePath;
54
54
  }
55
55
 
56
- export function throwIfAborted(signal: AbortSignal | undefined): void {
57
- if (signal?.aborted) throw new Error("Operation aborted");
58
- }
59
-
60
56
  export function snapshotsEqual(left: FileSnapshot, right: FileSnapshot): boolean {
61
57
  return left.digest === right.digest;
62
58
  }
@@ -27,7 +27,6 @@ import {
27
27
  type FileSnapshot,
28
28
  requireAbsolutePath,
29
29
  snapshotsEqual,
30
- throwIfAborted,
31
30
  } from "./common.js";
32
31
  import { convertLeadingTabsToSpaces, findActualString, preserveQuoteStyle } from "./edit-utils.js";
33
32
 
@@ -256,7 +255,7 @@ export function registerFileTools(
256
255
  { additionalProperties: false },
257
256
  ),
258
257
  async execute(_id, params, signal, _onUpdate, ctx) {
259
- throwIfAborted(signal);
258
+ signal?.throwIfAborted();
260
259
  const filePath = requireAbsolutePath(params.file_path);
261
260
  if (
262
261
  params.offset !== undefined &&
@@ -280,7 +279,7 @@ export function registerFileTools(
280
279
  }
281
280
  throw error;
282
281
  }
283
- throwIfAborted(signal);
282
+ signal?.throwIfAborted();
284
283
 
285
284
  const extension = extname(filePath).toLowerCase();
286
285
  if (extension === ".pdf") {
@@ -392,7 +391,7 @@ export function registerFileTools(
392
391
  { additionalProperties: false },
393
392
  ),
394
393
  async execute(_id, params, signal, _onUpdate, ctx) {
395
- throwIfAborted(signal);
394
+ signal?.throwIfAborted();
396
395
  const filePath = requireAbsolutePath(params.file_path);
397
396
  await guardWriteAccess(ctx, {
398
397
  toolName: "Edit",
@@ -438,7 +437,7 @@ export function registerFileTools(
438
437
  const key = await readStateKey(filePath);
439
438
  state.reads.set(key, snapshot);
440
439
  const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
441
- throwIfAborted(signal);
440
+ signal?.throwIfAborted();
442
441
  const {
443
442
  text: diagnosticText,
444
443
  errorCount,
@@ -495,7 +494,7 @@ export function registerFileTools(
495
494
  const key = await readStateKey(filePath);
496
495
  requireCurrentRead(state, key, filePath, content);
497
496
  await access(filePath, constants.R_OK | constants.W_OK);
498
- throwIfAborted(signal);
497
+ signal?.throwIfAborted();
499
498
  const original = content.toString("utf8");
500
499
 
501
500
  // CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
@@ -541,7 +540,7 @@ export function registerFileTools(
541
540
  const text = replaceAll
542
541
  ? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
543
542
  : `The file ${filePath} has been updated successfully.`;
544
- throwIfAborted(signal);
543
+ signal?.throwIfAborted();
545
544
  const {
546
545
  text: diagnosticText,
547
546
  errorCount,
@@ -602,7 +601,7 @@ export function registerFileTools(
602
601
  { additionalProperties: false },
603
602
  ),
604
603
  async execute(_id, params, signal, _onUpdate, ctx) {
605
- throwIfAborted(signal);
604
+ signal?.throwIfAborted();
606
605
  const filePath = requireAbsolutePath(params.file_path);
607
606
  await guardWriteAccess(ctx, {
608
607
  toolName: "Write",
@@ -628,7 +627,7 @@ export function registerFileTools(
628
627
  throw error;
629
628
  }
630
629
  }
631
- throwIfAborted(signal);
630
+ signal?.throwIfAborted();
632
631
  await mkdir(dirname(filePath), { recursive: true });
633
632
  await writeFile(filePath, params.content, "utf8");
634
633
  const snapshot = snapshotOf(params.content);
@@ -640,7 +639,7 @@ export function registerFileTools(
640
639
  original === undefined
641
640
  ? `File created successfully at: ${filePath}`
642
641
  : `The file ${filePath} has been updated successfully.`;
643
- throwIfAborted(signal);
642
+ signal?.throwIfAborted();
644
643
  const {
645
644
  text: diagnosticText,
646
645
  errorCount,
@@ -15,7 +15,7 @@ import { promisify } from "node:util";
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
  import { Type } from "typebox";
17
17
 
18
- import { searchRoot, suggestPathUnderCwd, throwIfAborted, toRelativePath } from "./common.js";
18
+ import { searchRoot, suggestPathUnderCwd, toRelativePath } from "./common.js";
19
19
 
20
20
  const GLOB_RESULT_LIMIT = 100;
21
21
 
@@ -58,7 +58,7 @@ export async function globFiles(
58
58
  cwd: string,
59
59
  signal?: AbortSignal,
60
60
  ): Promise<{ files: string[]; truncated: boolean }> {
61
- throwIfAborted(signal);
61
+ signal?.throwIfAborted();
62
62
  let searchDir = cwd;
63
63
  let searchPattern = pattern;
64
64
  if (isAbsolute(pattern)) {
@@ -87,7 +87,6 @@ export async function globFiles(
87
87
  });
88
88
  stdout = result.stdout;
89
89
  } catch (error) {
90
- if (signal?.aborted) throwIfAborted(signal);
91
90
  // rg exit code 1 = 搜索完成但无匹配,对齐 Claude Code 的 ripGrep(正常空结果)
92
91
  const code = (error as { code?: unknown }).code;
93
92
  if (code === 1) return { files: [], truncated: false };
@@ -17,7 +17,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
18
  import { type Static, Type } from "typebox";
19
19
 
20
- import { searchRoot, suggestPathUnderCwd, throwIfAborted, toRelativePath } from "./common.js";
20
+ import { searchRoot, suggestPathUnderCwd, toRelativePath } from "./common.js";
21
21
 
22
22
  const GREP_OUTPUT_MODES = ["content", "files_with_matches", "count"] as const;
23
23
 
@@ -272,7 +272,7 @@ export function registerGrepTool(pi: ExtensionAPI): void {
272
272
  }
273
273
  }
274
274
  const result = await pi.exec("rg", buildGrepArguments(params, ctx.cwd), { signal });
275
- throwIfAborted(signal);
275
+ signal?.throwIfAborted();
276
276
  if (result.code !== 0 && result.code !== 1) {
277
277
  throw new Error(result.stderr.trim() || `ripgrep exited with code ${result.code}`);
278
278
  }
@@ -193,7 +193,7 @@ export function registerSessionTools(pi: ExtensionAPI): void {
193
193
  if (!ctx.hasUI) throw new Error("Cannot ask questions: interactive UI is not available");
194
194
  const answers: Record<string, string> = {};
195
195
  for (const question of params.questions) {
196
- if (signal?.aborted) throw new Error("Operation aborted");
196
+ signal?.throwIfAborted();
197
197
  answers[question.question] = question.multiSelect
198
198
  ? await askMultiple(question, ctx, signal)
199
199
  : await askSingle(question, ctx, signal);
@@ -6,10 +6,11 @@ import {
6
6
  type BashToolDetails,
7
7
  type ExtensionAPI,
8
8
  formatSize,
9
+ type TruncationResult,
9
10
  } from "@earendil-works/pi-coding-agent";
10
11
  import { Type } from "typebox";
11
12
 
12
- import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
13
+ import { BashInterruptedError, type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
13
14
  import { resolveWorkdir } from "../lib/path.js";
14
15
 
15
16
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -36,6 +37,25 @@ function formatBashError(exitCode: number | null, output: string): string {
36
37
  );
37
38
  }
38
39
 
40
+ /** 输出被截断时附加的 `[Showing lines...]` 提示(成功、超时、中断路径共用)。 */
41
+ function appendTruncationNotice(
42
+ text: string,
43
+ truncation: TruncationResult,
44
+ fullOutputPath: string | undefined,
45
+ ): string {
46
+ if (!fullOutputPath || !truncation.truncated) return text;
47
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
48
+ const endLine = truncation.totalLines;
49
+ if (truncation.lastLinePartial) {
50
+ const lastLineSize = formatSize(text.length - text.lastIndexOf("\n", text.length - 2) - 1);
51
+ return `${text}\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${fullOutputPath}]`;
52
+ }
53
+ if (truncation.truncatedBy === "lines") {
54
+ return `${text}\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${fullOutputPath}]`;
55
+ }
56
+ return `${text}\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${fullOutputPath}]`;
57
+ }
58
+
39
59
  /**
40
60
  * 成功路径:消费 runtime 的截断结果(输出已由 runtime 截断并落盘),
41
61
  * 截断时追加 `[Showing lines X-Y of N. Full output: path]` 提示。
@@ -46,21 +66,7 @@ export function formatBashSuccess(result: Awaited<ReturnType<BwrapRuntime["execu
46
66
  details: BashToolDetails | undefined;
47
67
  } {
48
68
  const { output, truncation, fullOutputPath } = result;
49
- let text = output || "(no output)";
50
- if (fullOutputPath && truncation.truncated) {
51
- const startLine = truncation.totalLines - truncation.outputLines + 1;
52
- const endLine = truncation.totalLines;
53
- if (truncation.lastLinePartial) {
54
- const lastLineSize = formatSize(
55
- output.length - output.lastIndexOf("\n", output.length - 2) - 1,
56
- );
57
- text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${fullOutputPath}]`;
58
- } else if (truncation.truncatedBy === "lines") {
59
- text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${fullOutputPath}]`;
60
- } else {
61
- text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${fullOutputPath}]`;
62
- }
63
- }
69
+ const text = appendTruncationNotice(output || "(no output)", truncation, fullOutputPath);
64
70
  return {
65
71
  content: [{ type: "text", text }],
66
72
  details: fullOutputPath && truncation.truncated ? { truncation, fullOutputPath } : undefined,
@@ -133,13 +139,24 @@ export function registerShellTools(
133
139
  });
134
140
  } catch (error) {
135
141
  if (!(error instanceof Error)) throw error;
136
- const timeoutMatch = /Command timed out after [\d.]+ seconds/.exec(error.message);
137
- const message = timeoutMatch
138
- ? error.message.slice(0, timeoutMatch.index) +
139
- `Command timed out after ${timeout} milliseconds` +
140
- error.message.slice(timeoutMatch.index + timeoutMatch[0].length)
141
- : error.message;
142
- throw new Error(message, { cause: error });
142
+ if (error instanceof BashInterruptedError) {
143
+ // 输出在前(必要时带截断提示),状态文本在最后
144
+ const text = appendTruncationNotice(
145
+ error.partial.output || "",
146
+ error.partial.truncation,
147
+ error.partial.fullOutputPath,
148
+ );
149
+ if (error.kind === "aborted") {
150
+ // 用户取消:直接返回已捕获的输出,不抛错
151
+ const full = text ? `${text}\n\nCommand aborted` : "Command aborted";
152
+ return { content: [{ type: "text", text: full }], details: undefined };
153
+ }
154
+ const full = text
155
+ ? `${text}\n\nCommand timed out after ${timeout} milliseconds`
156
+ : `Command timed out after ${timeout} milliseconds`;
157
+ throw new Error(full, { cause: error });
158
+ }
159
+ throw error;
143
160
  }
144
161
 
145
162
  // 对齐 Claude Code:非 0 退出码视为错误(不做 grep/find 等命令语义化特判,
package/src/lib/path.ts CHANGED
@@ -61,7 +61,7 @@ export function formatDisplayPath(cwd: string, filePath: string): string {
61
61
  }
62
62
 
63
63
  /** subtitle 中路径的最大显示长度,超过时退化为 `parent/basename`。 */
64
- export const MAX_SUBTITLE_PATH_LENGTH = 20;
64
+ export const MAX_SUBTITLE_PATH_LENGTH = 30;
65
65
 
66
66
  function shortenSubtitlePath(filePath: string, display: string): string {
67
67
  if (display.length <= MAX_SUBTITLE_PATH_LENGTH) return display;
@@ -1,10 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { fileURLToPath } from "node:url";
3
3
 
4
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
6
 
7
- import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
7
+ import { BashInterruptedError, type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
8
8
  import { resolveWorkdir } from "../lib/path.js";
9
9
 
10
10
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -16,6 +16,18 @@ const CAPTURE_TRUNCATED_NOTICE = "[output capture truncated at the in-memory saf
16
16
  /** Bash tool guidance, kept in markdown so it reads like documentation. */
17
17
  const BASH_PROMPT = readFileSync(fileURLToPath(new URL("bash.md", import.meta.url)), "utf8").trim();
18
18
 
19
+ /** opencode 风格截断提示(成功、超时、中断路径共用)。 */
20
+ function appendTruncationNotice(
21
+ text: string,
22
+ truncation: TruncationResult,
23
+ fullOutputPath: string | undefined,
24
+ ): string {
25
+ if (!truncation.truncated) return text;
26
+ let out = `${text}\n\n${CAPTURE_TRUNCATED_NOTICE}`;
27
+ if (fullOutputPath) out += `\nFull output: ${fullOutputPath}`;
28
+ return out;
29
+ }
30
+
19
31
  /**
20
32
  * 对齐上游 opencode(packages/core/src/tool/bash.ts):
21
33
  * 命令失败(非 0 退出码)与超时都不抛错,输出与状态文本一起返回,
@@ -89,17 +101,21 @@ export default function opencodeBash(
89
101
  });
90
102
  } catch (error) {
91
103
  if (!(error instanceof Error)) throw error;
92
- // 对齐上游 opencode:超时不抛错,返回提示文本(丢弃部分输出)
93
- if (/Command timed out after [\d.]+ seconds/.test(error.message)) {
104
+ if (error instanceof BashInterruptedError) {
105
+ const text = appendTruncationNotice(
106
+ error.partial.output || "",
107
+ error.partial.truncation,
108
+ error.partial.fullOutputPath,
109
+ );
110
+ const status =
111
+ error.kind === "timeout"
112
+ ? `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
113
+ : "Command aborted";
114
+ const full = text ? `${text}\n\n${status}` : status;
115
+ // 对齐上游 opencode:超时与中断都不抛错,输出与状态文本一起返回
94
116
  return {
95
- content: [
96
- {
97
- type: "text" as const,
98
- text: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
99
- },
100
- { type: "text" as const, text: "Command timed out before completion." },
101
- ],
102
- details: { timeout: true },
117
+ content: [{ type: "text" as const, text: full }],
118
+ details: error.kind === "timeout" ? { timeout: true } : {},
103
119
  };
104
120
  }
105
121
  throw error;
@@ -107,10 +123,7 @@ export default function opencodeBash(
107
123
 
108
124
  // 命令失败(非 0 退出码)不抛错:输出与状态文本一起返回
109
125
  let text = result.output || "(no output)";
110
- if (result.truncation.truncated) {
111
- text += `\n\n${CAPTURE_TRUNCATED_NOTICE}`;
112
- if (result.fullOutputPath) text += `\nFull output: ${result.fullOutputPath}`;
113
- }
126
+ text = appendTruncationNotice(text, result.truncation, result.fullOutputPath);
114
127
  return {
115
128
  content: [
116
129
  { type: "text" as const, text },