@trim21/personal-pi-extensions 0.1.612 → 0.1.619

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.
Files changed (77) hide show
  1. package/package.json +1 -1
  2. package/src/aft/config.ts +9 -3
  3. package/src/aft/index.ts +6 -2
  4. package/src/aft/logger.ts +9 -3
  5. package/src/aft/tools.ts +3 -1
  6. package/src/bwrap/README.md +19 -10
  7. package/src/bwrap/approval-rules.ts +42 -14
  8. package/src/bwrap/approval-suggest.ts +12 -4
  9. package/src/bwrap/core.ts +39 -13
  10. package/src/bwrap/dcg-scan.ts +16 -6
  11. package/src/bwrap/holder.ts +3 -1
  12. package/src/bwrap/mihomo-config.ts +18 -6
  13. package/src/bwrap/network-stack.ts +69 -24
  14. package/src/bwrap/runtime.ts +30 -10
  15. package/src/bwrap/sandbox.ts +1 -1
  16. package/src/claude-code/common.ts +6 -2
  17. package/src/claude-code/edit-utils.ts +3 -1
  18. package/src/claude-code/files.ts +40 -14
  19. package/src/claude-code/glob.ts +12 -4
  20. package/src/claude-code/grep.ts +41 -14
  21. package/src/claude-code/session-tools.ts +18 -6
  22. package/src/claude-code/shell.ts +9 -3
  23. package/src/gh/base.ts +72 -24
  24. package/src/gh/tools/download-release-assets.ts +12 -4
  25. package/src/gh/tools/list-releases.ts +3 -1
  26. package/src/gh/tools/list-workflow-runs.ts +9 -3
  27. package/src/gh/tools/read-ci-logs.ts +21 -7
  28. package/src/gh/tools/read-repo.ts +3 -1
  29. package/src/lib/cli-args.ts +6 -2
  30. package/src/lib/cli.ts +48 -16
  31. package/src/lib/file-reads.ts +24 -8
  32. package/src/lib/github.ts +35 -12
  33. package/src/lib/lsp/adapter.ts +6 -2
  34. package/src/lib/lsp/bin.ts +12 -4
  35. package/src/lib/lsp/client.ts +205 -69
  36. package/src/lib/lsp/diagnostic.ts +6 -2
  37. package/src/lib/lsp/inspect-tool.ts +25 -9
  38. package/src/lib/lsp/lsp.ts +138 -46
  39. package/src/lib/lsp/rename-tool.ts +11 -4
  40. package/src/lib/lsp/rename.ts +25 -8
  41. package/src/lib/lsp/server-config.ts +41 -14
  42. package/src/lib/lsp/watcher.ts +51 -17
  43. package/src/lib/path.ts +18 -6
  44. package/src/lib/proc.ts +3 -1
  45. package/src/lib/proxy.ts +9 -3
  46. package/src/lib/request-policy.ts +3 -1
  47. package/src/lib/seq-state.ts +3 -1
  48. package/src/lib/ui.ts +23 -8
  49. package/src/lib/write-guard.ts +21 -7
  50. package/src/openai-cost/config.ts +3 -1
  51. package/src/openai-cost/cost.ts +54 -18
  52. package/src/openai-cost/index.ts +3 -1
  53. package/src/openai-cost/provider.ts +6 -2
  54. package/src/opencode/bash.ts +9 -3
  55. package/src/opencode/edit-engine.ts +24 -8
  56. package/src/opencode/files.ts +30 -10
  57. package/src/opencode/glob.ts +3 -1
  58. package/src/opencode/grep.ts +12 -4
  59. package/src/opencode/question.ts +3 -1
  60. package/src/opencode/ripgrep.ts +18 -6
  61. package/src/opencode/todo.ts +3 -1
  62. package/src/session-name.ts +39 -13
  63. package/src/spawn-agent-agents.ts +18 -6
  64. package/src/spawn-agent.ts +53 -18
  65. package/src/system-prompt/index.ts +9 -3
  66. package/src/talk/core.ts +143 -48
  67. package/src/talk/format.ts +12 -4
  68. package/src/talk/group.ts +9 -3
  69. package/src/talk/index.ts +30 -10
  70. package/src/talk/mailbox.ts +30 -10
  71. package/src/talk/policy.ts +6 -2
  72. package/src/talk/registry.ts +33 -11
  73. package/src/talk/storage.ts +3 -1
  74. package/src/vision-agent.ts +41 -14
  75. package/src/web/config.ts +3 -1
  76. package/src/web/fetch.ts +75 -25
  77. package/src/web/search.ts +30 -10
@@ -1,6 +1,6 @@
1
1
  import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { mkdir, readFile, readlink, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, readlink, rm, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
@@ -84,7 +84,9 @@ const HOLDER_PATH = fileURLToPath(new URL("holder.js", import.meta.url));
84
84
  const SBIN_PATH_SUFFIX = "/usr/local/sbin:/usr/sbin:/sbin";
85
85
 
86
86
  function killProcess(pid: number | undefined, signal: NodeJS.Signals = "SIGTERM"): void {
87
- if (!pid) return;
87
+ if (!pid) {
88
+ return;
89
+ }
88
90
  try {
89
91
  process.kill(pid, signal);
90
92
  } catch {
@@ -92,6 +94,17 @@ function killProcess(pid: number | undefined, signal: NodeJS.Signals = "SIGTERM"
92
94
  }
93
95
  }
94
96
 
97
+ /**
98
+ * 终止 holder(unshare 包装进程)必须用 SIGKILL:util-linux 的 unshare 在 fork 前
99
+ * `sigprocmask(SIG_BLOCK, {SIGINT, SIGTERM})`,且只在子进程里恢复掩码——父进程永久
100
+ * 阻塞这两个信号,发给它的 SIGTERM 只会 pending 永不投递(实测 3s 后仍存活)。
101
+ * SIGKILL 不可阻塞,unshare 立即退出;其子进程(pid ns 的 init)经 --kill-child 的
102
+ * PDEATHSIG 收到 SIGTERM,走优雅退出并触发内核清理整个 pid ns。
103
+ */
104
+ function killHolder(pid: number | undefined): void {
105
+ killProcess(pid, "SIGKILL");
106
+ }
107
+
95
108
  /** 轮询等待进程退出(进程消失即返回)。 */
96
109
  async function waitForExit(pid: number, timeoutMs = 2000): Promise<void> {
97
110
  const deadline = Date.now() + timeoutMs;
@@ -105,7 +118,7 @@ async function waitForExit(pid: number, timeoutMs = 2000): Promise<void> {
105
118
  }
106
119
  }
107
120
 
108
- /** 读取进程的直接子进程 pid:--kill-child 只转发信号给 fork 的子进程,兜底直接 SIGKILL 用。 */
121
+ /** 读取 holder 的直接子进程 pid(pid ns 的 init):PDEATHSIG 未生效时的 SIGKILL 兜底用。 */
109
122
  async function readChildPids(pid: number): Promise<number[]> {
110
123
  try {
111
124
  const content = await readFile(`/proc/${pid}/task/${pid}/children`, "utf8");
@@ -136,7 +149,9 @@ async function waitForNewUserns(pid: number, timeoutMs = 5000): Promise<void> {
136
149
  const deadline = Date.now() + timeoutMs;
137
150
  while (Date.now() < deadline) {
138
151
  try {
139
- if ((await readlink(`/proc/${pid}/ns/user`)) !== self) return;
152
+ if ((await readlink(`/proc/${pid}/ns/user`)) !== self) {
153
+ return;
154
+ }
140
155
  } catch {
141
156
  // /proc/<pid> 尚未就绪,重试
142
157
  }
@@ -153,12 +168,16 @@ function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<
153
168
  return new Promise((resolve, reject) => {
154
169
  let settled = false;
155
170
  const timer = setTimeout(() => {
156
- if (settled) return;
171
+ if (settled) {
172
+ return;
173
+ }
157
174
  settled = true;
158
175
  reject(new Error("Timed out waiting for mihomo to start"));
159
176
  }, timeoutMs);
160
177
  const onReady = (): void => {
161
- if (settled) return;
178
+ if (settled) {
179
+ return;
180
+ }
162
181
  settled = true;
163
182
  clearTimeout(timer);
164
183
  resolve();
@@ -168,7 +187,9 @@ function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<
168
187
  if (stream) {
169
188
  // 不提前停止:holder 生命周期内持续消费输出,避免流无消费者触发背压
170
189
  void forEachLine(stream, (line) => {
171
- if (line.includes("Tun adapter listening")) onReady();
190
+ if (line.includes("Tun adapter listening")) {
191
+ onReady();
192
+ }
172
193
  }).catch(() => {
173
194
  // 就绪判定由超时与 holder exit 兜底,流的 error 忽略
174
195
  });
@@ -178,7 +199,9 @@ function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<
178
199
  }
179
200
 
180
201
  function killChild(pid: number | undefined): void {
181
- if (!pid) return;
202
+ if (!pid) {
203
+ return;
204
+ }
182
205
  try {
183
206
  process.kill(-pid, "SIGKILL");
184
207
  } catch {
@@ -223,14 +246,19 @@ export interface NetworkStack {
223
246
  interface NetworkStackState {
224
247
  holderPid: number;
225
248
  slirpPid: number;
249
+ /** 过滤进程的工作目录:GC 兜底路径也要把它删掉。 */
250
+ mihomoHome: string;
226
251
  }
227
252
 
228
- /** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill 残留进程。 */
253
+ /** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill 残留进程并清掉工作目录。 */
229
254
  const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
230
- // SIGTERM 经 unshare --kill-child 转发给 init,内核清理 pid ns 内全部进程;
255
+ // holder 退出触发内核清理 pid ns 内全部进程;
231
256
  // slirp4netns 在宿主侧持有 tap fd,单独终止
232
- killProcess(state.holderPid);
257
+ killHolder(state.holderPid);
233
258
  killProcess(state.slirpPid);
259
+ // FinalizationRegistry 回调不能 await:尽力而为,删不掉就留下(宿主崩溃时同样如此)
260
+ // eslint-disable-next-line unicorn/no-useless-undefined
261
+ void rm(state.mihomoHome, { recursive: true, force: true }).catch(() => undefined);
234
262
  });
235
263
 
236
264
  /**
@@ -252,7 +280,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
252
280
 
253
281
  // mihomo 工作目录(-d):cache.db 等落在这里,而不是它默认的 ~/.config/mihomo/
254
282
  //(后者不存在时 mihomo 每次启动都告警且 fakeip 映射无持久化)。每次启动用独立
255
- // uuid 目录,避免并发的多个 holder 争抢 bbolt 文件锁。
283
+ // uuid 目录,避免并发的多个 holder 争抢 bbolt 文件锁;正常停栈与 GC 兜底删除该
284
+ // 目录(见 stop / stackFinalizer),启动失败时保留作诊断材料(见 catch)。
256
285
  const mihomoHome = join(getAgentDir(), "tmp", `mihomo-${randomUUID()}`);
257
286
  await mkdir(mihomoHome, { recursive: true });
258
287
 
@@ -263,7 +292,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
263
292
  try {
264
293
  // unshare -p --fork:node 成为 pid namespace 的 init,任何方式退出(含 SIGKILL)
265
294
  // 内核都会清理 pid ns 内全部进程(mihomo),ns 引用随之归零;
266
- // --kill-child=SIGTERM:宿主侧 SIGTERM unshare 时转发给 init 走优雅退出
295
+ // --kill-child=SIGTERM:init 拿到 PR_SET_PDEATHSIG,unshare 死亡时收到 SIGTERM
296
+ // 走优雅退出(注意 unshare 自身阻塞 SIGINT/SIGTERM,终止它只能靠 SIGKILL,见 killHolder)
267
297
  holder = spawn(
268
298
  "unshare",
269
299
  [
@@ -359,7 +389,7 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
359
389
  mihomoReady.catch(() => undefined);
360
390
  });
361
391
 
362
- const state: NetworkStackState = { holderPid, slirpPid: slirp.pid };
392
+ const state: NetworkStackState = { holderPid, slirpPid: slirp.pid, mihomoHome };
363
393
  const stack: NetworkStack = {
364
394
  exec: async (execOptions: NetworkStackExecOptions) => {
365
395
  const child = spawn(
@@ -404,14 +434,20 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
404
434
  execOptions.signal?.addEventListener("abort", onAbort, { once: true });
405
435
 
406
436
  child.once("error", (error) => {
407
- if (settled) return;
437
+ if (settled) {
438
+ return;
439
+ }
408
440
  settled = true;
409
441
  reject(error);
410
442
  });
411
443
  child.once("close", (exitCode) => {
412
- if (settled) return;
444
+ if (settled) {
445
+ return;
446
+ }
413
447
  settled = true;
414
- if (timeoutHandle) clearTimeout(timeoutHandle);
448
+ if (timeoutHandle) {
449
+ clearTimeout(timeoutHandle);
450
+ }
415
451
  execOptions.signal?.removeEventListener("abort", onAbort);
416
452
  // 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
417
453
  if (execOptions.signal?.aborted) {
@@ -423,7 +459,9 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
423
459
  } else if (timedOut) {
424
460
  // 超时:name=TimeoutError(对齐标准错误分类)
425
461
  reject(new TimeoutError(execOptions.timeout));
426
- } else resolve({ exitCode });
462
+ } else {
463
+ resolve({ exitCode });
464
+ }
427
465
  });
428
466
  });
429
467
  },
@@ -432,35 +470,42 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
432
470
  // slirp4netns 持有 tap fd(pin 住 netns),必须随 holder 一起显式终止;
433
471
  // 先杀它再杀 holder,避免 stop() 与 exit-fd HUP 的收尾时序竞争
434
472
  killProcess(state.slirpPid);
435
- // SIGTERM unshare → --kill-child 转发 SIGTERM 给 init(pid ns 的 pid 1),
436
- // init 优雅停 mihomo 后退出,内核清理 pid ns 内全部进程,ns 引用随之归零
437
- killProcess(state.holderPid);
473
+ // SIGKILL holder → init 经 PDEATHSIG 收到 SIGTERM,优雅停 mihomo 后退出,
474
+ // 内核清理 pid ns 内全部进程,ns 引用随之归零(毫秒级,见 killHolder)
475
+ killHolder(state.holderPid);
438
476
  await waitForExit(state.holderPid);
439
477
  await waitForExit(state.slirpPid);
440
478
  // 兜底:init 未在超时内退出 → SIGKILL init → 内核清 pid ns
441
479
  for (const pid of children) {
442
480
  killProcess(pid, "SIGKILL");
443
481
  }
482
+ // 工作目录只服务本次实例(mihomo 的 cache.db 等运行时缓存),随停栈删除;
483
+ // best-effort:删除失败不外抛,避免掩盖命令结果
484
+ // eslint-disable-next-line unicorn/no-useless-undefined
485
+ await rm(state.mihomoHome, { recursive: true, force: true }).catch(() => undefined);
444
486
  },
445
487
  holderPid,
446
488
  };
447
489
  stackFinalizer.register(stack, state);
448
490
  return stack;
449
491
  } catch (error) {
450
- // 失败清理:holder(unshare)的 SIGTERM 经 --kill-child 转发给 init,
451
- // init 退出时内核清理 pid ns 内全部进程;slirp4netns 在宿主侧,需单独终止
492
+ // 失败清理:SIGKILL holder(unshare)→ init 经 PDEATHSIG 收到 SIGTERM 后退出,
493
+ // 内核清理 pid ns 内全部进程;slirp4netns 在宿主侧,需单独终止
452
494
  //(exit-fd 写端也会随 holder 死亡关闭,这里主动杀只是不等到 HUP 轮询)
453
495
  if (slirp?.pid) {
454
496
  killProcess(slirp.pid);
455
497
  }
456
498
  if (holder?.pid) {
457
499
  const children = await readChildPids(holder.pid);
458
- killProcess(holder.pid);
500
+ killHolder(holder.pid);
459
501
  await waitForExit(holder.pid);
460
502
  for (const pid of children) {
461
503
  killProcess(pid, "SIGKILL");
462
504
  }
463
505
  }
506
+ // 这里刻意不删 mihomoHome:启动失败时它属于现场材料,与下面落盘的诊断日志
507
+ //(holder / slirp 输出 + 错误本身)配套保留,便于事后排查;失败路径罕见,
508
+ // 留一个目录不构成泄漏
464
509
  const logPath = await writeFailureLog(error, [holderLog.join(""), slirpLog.join("")]);
465
510
  if (logPath !== undefined && error instanceof Error) {
466
511
  throw new Error(`${error.message}\n(sandbox startup diagnostics: ${logPath})`, {
@@ -166,7 +166,9 @@ const BASH_TAIL_LIMIT_BYTES = 1024 * 1024;
166
166
  function countNewlines(data: Buffer): number {
167
167
  let count = 0;
168
168
  for (const byte of data) {
169
- if (byte === 0x0a) count++;
169
+ if (byte === 0x0a) {
170
+ count++;
171
+ }
170
172
  }
171
173
  return count;
172
174
  }
@@ -219,7 +221,9 @@ class BashOutput {
219
221
  }
220
222
 
221
223
  close(): Promise<void> {
222
- if (!this.stream) return Promise.resolve();
224
+ if (!this.stream) {
225
+ return Promise.resolve();
226
+ }
223
227
  const stream = this.stream;
224
228
  this.stream = undefined;
225
229
  return new Promise((resolve) => {
@@ -286,8 +290,12 @@ function notifyChange(
286
290
  ctx: { ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } },
287
291
  change: SandboxChange,
288
292
  ): void {
289
- if (change.fs !== undefined) ctx.ui.notify(FS_LABELS[change.fs], "info");
290
- if (change.network !== undefined) ctx.ui.notify(NETWORK_LABELS[change.network], "info");
293
+ if (change.fs !== undefined) {
294
+ ctx.ui.notify(FS_LABELS[change.fs], "info");
295
+ }
296
+ if (change.network !== undefined) {
297
+ ctx.ui.notify(NETWORK_LABELS[change.network], "info");
298
+ }
291
299
  }
292
300
 
293
301
  /** 沙箱默认写边界:根只读、工作区与 /tmp 可写、.git 只读。不展开用户配置的额外可写路径。 */
@@ -342,7 +350,9 @@ export function describeSandbox(
342
350
  unsandboxed: boolean,
343
351
  fixed = false,
344
352
  ): string | undefined {
345
- if (unsandboxed) return undefined;
353
+ if (unsandboxed) {
354
+ return undefined;
355
+ }
346
356
  return [
347
357
  "<system-reminder>",
348
358
  `This command ran in a sandbox: ${describeLimits(resolved)}.`,
@@ -464,7 +474,9 @@ export class BwrapRuntime {
464
474
 
465
475
  // 固定沙箱(创建时显式传入配置)不注册 /bwrap-* 命令:切模式与恢复提权审批
466
476
  // 都可能放宽声明的沙箱
467
- if (this.config === undefined) this.registerCommands(pi);
477
+ if (this.config === undefined) {
478
+ this.registerCommands(pi);
479
+ }
468
480
  }
469
481
 
470
482
  setMode(cwd: string, change: SandboxChange): ResolvedBwrap {
@@ -663,7 +675,9 @@ export class BwrapRuntime {
663
675
  }
664
676
 
665
677
  private resolve(ctx: Pick<ExtensionContext, "cwd">): ResolvedBwrap {
666
- if (!this.resolved) this.resolved = resolveBwrap(this.config ?? loadBwrapConfig(ctx.cwd));
678
+ if (!this.resolved) {
679
+ this.resolved = resolveBwrap(this.config ?? loadBwrapConfig(ctx.cwd));
680
+ }
667
681
  return this.resolved;
668
682
  }
669
683
 
@@ -674,7 +688,9 @@ export class BwrapRuntime {
674
688
  execCwd: string,
675
689
  ): Promise<FullAccessGrant> {
676
690
  // hasUI 判定推迟到审批时刻:无 UI 会话弹不了审批框,按用户点 Deny 的标准文案拒绝
677
- if (!ctx.hasUI) throw new Error(UNSANDBOXED_DENIED);
691
+ if (!ctx.hasUI) {
692
+ throw new Error(UNSANDBOXED_DENIED);
693
+ }
678
694
  const decision = await this.approveFullAccessUI(ctx, command, reason, execCwd);
679
695
  // 关闭对话框 = 中断并拒绝,不循环重问
680
696
  if (decision === undefined) {
@@ -802,7 +818,9 @@ export class BwrapRuntime {
802
818
  const verdict = await selectWithOptionalInput(description + pending, actions, ctx.ui, {
803
819
  signal: ctx.signal,
804
820
  });
805
- if (verdict === undefined) return undefined;
821
+ if (verdict === undefined) {
822
+ return undefined;
823
+ }
806
824
  if (verdict.label !== EDIT_RULES) {
807
825
  return {
808
826
  result: verdict.label,
@@ -826,7 +844,9 @@ export class BwrapRuntime {
826
844
  patterns?: string[],
827
845
  ): Promise<void> {
828
846
  const rulePatterns = patterns ?? (await commandPatternsFor(command));
829
- if (rulePatterns.length === 0) return; // 解析失败:本次处理,不写规则
847
+ if (rulePatterns.length === 0) {
848
+ return; // 解析失败:本次处理,不写规则
849
+ }
830
850
  const newRules: ApprovalRule[] = rulePatterns.map((pattern) => ({ action: "allow", pattern }));
831
851
  const { project } = getBwrapConfigPaths(ctx.cwd);
832
852
  let config: Record<string, unknown> = {};
@@ -117,7 +117,7 @@ export async function runInSandbox(
117
117
  const workspace = expandHome(options.workspace);
118
118
  const commandCwd = expandHome(options.commandCwd ?? workspace);
119
119
  const local = options.unsandboxed === true || !resolved.bwrapEnabled;
120
- // 每次执行现建网络栈(启动约 140ms),作用域结束即停栈:allowlist 变更即时生效
120
+ // 每次执行现建现停网络栈(起栈 ~40ms、停栈 ~100ms),作用域结束即停栈:allowlist 变更即时生效
121
121
  const stack = local ? undefined : await createNetworkStack(resolved, options.log);
122
122
  try {
123
123
  if (stack) {
@@ -14,7 +14,9 @@ export function resolveToolFilePath(filePath: string, cwd: string): string {
14
14
 
15
15
  /** Resolve a search root path against the working directory. */
16
16
  export function searchRoot(path: string | undefined, cwd: string): string {
17
- if (!path) return cwd;
17
+ if (!path) {
18
+ return cwd;
19
+ }
18
20
  return isAbsolute(path) ? path : resolve(cwd, path);
19
21
  }
20
22
 
@@ -82,6 +84,8 @@ export async function suggestPathUnderCwd(
82
84
  /** "Did you mean" 建议:优先 cwd 重定位,其次同名不同扩展(对齐 Claude Code)。 */
83
85
  export async function didYouMean(filePath: string, cwd: string): Promise<string | undefined> {
84
86
  const cwdSuggestion = await suggestPathUnderCwd(filePath, cwd);
85
- if (cwdSuggestion) return cwdSuggestion;
87
+ if (cwdSuggestion) {
88
+ return cwdSuggestion;
89
+ }
86
90
  return findSimilarFile(filePath);
87
91
  }
@@ -12,6 +12,8 @@
12
12
  * convertLeadingTabsToSpaces)。仅用于 details 里展示的 diff,不影响写盘内容。
13
13
  */
14
14
  export function convertLeadingTabsToSpaces(content: string): string {
15
- if (!content.includes("\t")) return content;
15
+ if (!content.includes("\t")) {
16
+ return content;
17
+ }
16
18
  return content.replaceAll(/^\t+/gm, (tabs) => " ".repeat(tabs.length));
17
19
  }
@@ -44,9 +44,15 @@ const MAX_READ_TOKENS = 25_000;
44
44
  const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024; // 1 GiB
45
45
 
46
46
  function formatFileSize(bytes: number): string {
47
- if (bytes < 1024) return `${bytes} B`;
48
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
49
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
47
+ if (bytes < 1024) {
48
+ return `${bytes} B`;
49
+ }
50
+ if (bytes < 1024 * 1024) {
51
+ return `${(bytes / 1024).toFixed(1)} KB`;
52
+ }
53
+ if (bytes < 1024 * 1024 * 1024) {
54
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
55
+ }
50
56
  return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
51
57
  }
52
58
 
@@ -81,7 +87,9 @@ async function assertReadableFile(filePath: string): Promise<Stats> {
81
87
  if (value.isDirectory()) {
82
88
  throw new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`);
83
89
  }
84
- if (!value.isFile()) throw new Error(`File not found: ${filePath}`);
90
+ if (!value.isFile()) {
91
+ throw new Error(`File not found: ${filePath}`);
92
+ }
85
93
  await access(filePath, constants.R_OK);
86
94
  return value;
87
95
  }
@@ -89,8 +97,12 @@ async function assertReadableFile(filePath: string): Promise<Stats> {
89
97
  function isBinary(sample: Uint8Array): boolean {
90
98
  let suspicious = 0;
91
99
  for (const byte of sample) {
92
- if (byte === 0) return true;
93
- if (byte < 9 || (byte > 13 && byte < 32)) suspicious++;
100
+ if (byte === 0) {
101
+ return true;
102
+ }
103
+ if (byte < 9 || (byte > 13 && byte < 32)) {
104
+ suspicious++;
105
+ }
94
106
  }
95
107
  return sample.length > 0 && suspicious / sample.length > 0.3;
96
108
  }
@@ -104,7 +116,9 @@ function isBinary(sample: Uint8Array): boolean {
104
116
  */
105
117
  function splitFileLines(content: string): string[] {
106
118
  const text = content.replace(/^\uFEFF/, "");
107
- if (text === "") return [];
119
+ if (text === "") {
120
+ return [];
121
+ }
108
122
  return (text.endsWith("\n") ? text : `${text}\n`)
109
123
  .split("\n")
110
124
  .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
@@ -157,7 +171,9 @@ export function formatReadOutput(
157
171
  }
158
172
 
159
173
  function countMatches(content: string, needle: string): number {
160
- if (needle === "") return 0;
174
+ if (needle === "") {
175
+ return 0;
176
+ }
161
177
  let count = 0;
162
178
  let index = 0;
163
179
  while ((index = content.indexOf(needle, index)) !== -1) {
@@ -173,17 +189,24 @@ export function exactReplace(
173
189
  newString: string,
174
190
  replaceAll = false,
175
191
  ): string {
176
- if (oldString === newString)
192
+ if (oldString === newString) {
177
193
  throw new Error("No changes to apply: old_string and new_string are identical.");
178
- if (oldString === "") throw new Error("old_string must not be empty.");
194
+ }
195
+ if (oldString === "") {
196
+ throw new Error("old_string must not be empty.");
197
+ }
179
198
  const matches = countMatches(content, oldString);
180
- if (matches === 0) throw new Error("String to replace not found in file.");
199
+ if (matches === 0) {
200
+ throw new Error("String to replace not found in file.");
201
+ }
181
202
  if (!replaceAll && matches > 1) {
182
203
  throw new Error(
183
204
  `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, provide more context to make old_string unique.`,
184
205
  );
185
206
  }
186
- if (replaceAll) return content.split(oldString).join(newString);
207
+ if (replaceAll) {
208
+ return content.split(oldString).join(newString);
209
+ }
187
210
  const index = content.indexOf(oldString);
188
211
  return content.slice(0, index) + newString + content.slice(index + oldString.length);
189
212
  }
@@ -284,8 +307,9 @@ export function registerFileTools(
284
307
  const key = await readStateKey(filePath);
285
308
  const buffer = await readFile(filePath);
286
309
 
287
- if (isBinary(buffer.subarray(0, SAMPLE_BYTES)))
310
+ if (isBinary(buffer.subarray(0, SAMPLE_BYTES))) {
288
311
  throw new Error(`Cannot read binary file: ${filePath}`);
312
+ }
289
313
  // 全读(limit 未传)时受字节上限约束(对齐 Claude Code)
290
314
  if (params.limit === undefined && buffer.length > MAX_READ_SIZE_BYTES) {
291
315
  throw new Error(
@@ -398,7 +422,9 @@ export function registerFileTools(
398
422
  throw error;
399
423
  }
400
424
  }
401
- if (!exists) await mkdir(dirname(filePath), { recursive: true });
425
+ if (!exists) {
426
+ await mkdir(dirname(filePath), { recursive: true });
427
+ }
402
428
  await writeFile(filePath, newString, "utf8");
403
429
  const snapshot = snapshotOf(newString);
404
430
  const key = await readStateKey(filePath);
@@ -40,11 +40,15 @@ function extractGlobBaseDirectory(pattern: string): {
40
40
  }
41
41
  const staticPrefix = pattern.slice(0, match.index);
42
42
  const lastSepIndex = Math.max(staticPrefix.lastIndexOf("/"), staticPrefix.lastIndexOf(sep));
43
- if (lastSepIndex === -1) return { baseDir: "", relativePattern: pattern };
43
+ if (lastSepIndex === -1) {
44
+ return { baseDir: "", relativePattern: pattern };
45
+ }
44
46
  let baseDir = staticPrefix.slice(0, lastSepIndex);
45
47
  const relativePattern = pattern.slice(lastSepIndex + 1);
46
48
  // 根目录 pattern(如 /*.txt):baseDir 为空但应使用 "/"
47
- if (baseDir === "" && lastSepIndex === 0) baseDir = "/";
49
+ if (baseDir === "" && lastSepIndex === 0) {
50
+ baseDir = "/";
51
+ }
48
52
  return { baseDir, relativePattern };
49
53
  }
50
54
 
@@ -89,7 +93,9 @@ export async function globFiles(
89
93
  } catch (error) {
90
94
  // rg exit code 1 = 搜索完成但无匹配,对齐 Claude Code 的 ripGrep(正常空结果)
91
95
  const code = (error as { code?: unknown }).code;
92
- if (code === 1) return { files: [], truncated: false };
96
+ if (code === 1) {
97
+ return { files: [], truncated: false };
98
+ }
93
99
  const detail =
94
100
  (error as { stderr?: string }).stderr?.trim() ||
95
101
  (error instanceof Error ? error.message : String(error));
@@ -143,7 +149,9 @@ export function registerGlobTool(pi: ExtensionAPI): void {
143
149
  }
144
150
  throw error;
145
151
  }
146
- if (!stats.isDirectory()) throw new Error(`Path is not a directory: ${params.path}`);
152
+ if (!stats.isDirectory()) {
153
+ throw new Error(`Path is not a directory: ${params.path}`);
154
+ }
147
155
  }
148
156
  const { files, truncated } = await globFiles(params.pattern, root, signal);
149
157
  const filenames = files.map((filePath) => toRelativePath(filePath, ctx.cwd));
@@ -35,7 +35,9 @@ const VCS_DIRECTORIES_TO_EXCLUDE = [".git", ".svn", ".hg", ".bzr", ".jj", ".sl"]
35
35
  const DEFAULT_HEAD_LIMIT = 250;
36
36
 
37
37
  function truncateOutput(output: string, maxCharacters = 30_000): string {
38
- if (output.length <= maxCharacters) return output;
38
+ if (output.length <= maxCharacters) {
39
+ return output;
40
+ }
39
41
  return `${output.slice(0, maxCharacters)}\n\n[Output truncated at ${maxCharacters} characters]`;
40
42
  }
41
43
 
@@ -102,22 +104,36 @@ export function buildGrepArguments(params: GrepParameters, cwd: string): string[
102
104
  case "content": {
103
105
  args.push("--no-heading", "--with-filename");
104
106
  const showLineNumbers = params["-n"] === true || params["-n"] === undefined;
105
- if (showLineNumbers) args.push("--line-number");
107
+ if (showLineNumbers) {
108
+ args.push("--line-number");
109
+ }
106
110
  const before = params["-B"];
107
111
  const after = params["-A"];
108
112
  const around = params.context ?? params["-C"];
109
113
  if (around === undefined) {
110
- if (before !== undefined) args.push("--before-context", String(before));
111
- if (after !== undefined) args.push("--after-context", String(after));
112
- } else args.push("--context", String(around));
114
+ if (before !== undefined) {
115
+ args.push("--before-context", String(before));
116
+ }
117
+ if (after !== undefined) {
118
+ args.push("--after-context", String(after));
119
+ }
120
+ } else {
121
+ args.push("--context", String(around));
122
+ }
113
123
 
114
124
  break;
115
125
  }
116
126
  // No default
117
127
  }
118
- if (params["-i"] === true) args.push("--ignore-case");
119
- if (params.type) args.push("--type", params.type);
120
- if (params.multiline === true) args.push("--multiline", "--multiline-dotall");
128
+ if (params["-i"] === true) {
129
+ args.push("--ignore-case");
130
+ }
131
+ if (params.type) {
132
+ args.push("--type", params.type);
133
+ }
134
+ if (params.multiline === true) {
135
+ args.push("--multiline", "--multiline-dotall");
136
+ }
121
137
  // glob 按逗号/空格拆分(花括号模式不拆),对齐 Claude Code
122
138
  if (params.glob) {
123
139
  const globPatterns: string[] = [];
@@ -129,12 +145,17 @@ export function buildGrepArguments(params: GrepParameters, cwd: string): string[
129
145
  }
130
146
  }
131
147
  for (const globPattern of globPatterns) {
132
- if (globPattern) args.push("--glob", globPattern);
148
+ if (globPattern) {
149
+ args.push("--glob", globPattern);
150
+ }
133
151
  }
134
152
  }
135
153
  // 以 - 开头的 pattern 用 -e 显式声明,防止被 rg 当作选项
136
- if (params.pattern.startsWith("-")) args.push("-e", params.pattern);
137
- else args.push(params.pattern);
154
+ if (params.pattern.startsWith("-")) {
155
+ args.push("-e", params.pattern);
156
+ } else {
157
+ args.push(params.pattern);
158
+ }
138
159
  args.push(searchRoot(params.path, cwd));
139
160
  return args;
140
161
  }
@@ -147,7 +168,9 @@ export function buildGrepArguments(params: GrepParameters, cwd: string): string[
147
168
  */
148
169
  export async function sortFilesByMtime(output: string): Promise<string> {
149
170
  const paths = output.replace(/\n$/, "").split("\n").filter(Boolean);
150
- if (paths.length <= 1) return output;
171
+ if (paths.length <= 1) {
172
+ return output;
173
+ }
151
174
  const stats = await Promise.allSettled(paths.map((path) => stat(path)));
152
175
  const sorted = paths
153
176
  .map((path, index) => ({
@@ -208,8 +231,12 @@ function formatLimitInfo(
208
231
  appliedOffset: number | undefined,
209
232
  ): string {
210
233
  const parts: string[] = [];
211
- if (appliedLimit !== undefined) parts.push(`limit: ${appliedLimit}`);
212
- if (appliedOffset) parts.push(`offset: ${appliedOffset}`);
234
+ if (appliedLimit !== undefined) {
235
+ parts.push(`limit: ${appliedLimit}`);
236
+ }
237
+ if (appliedOffset) {
238
+ parts.push(`offset: ${appliedOffset}`);
239
+ }
213
240
  return parts.join(", ");
214
241
  }
215
242