@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.
- package/package.json +1 -1
- package/src/aft/config.ts +9 -3
- package/src/aft/index.ts +6 -2
- package/src/aft/logger.ts +9 -3
- package/src/aft/tools.ts +3 -1
- package/src/bwrap/README.md +19 -10
- package/src/bwrap/approval-rules.ts +42 -14
- package/src/bwrap/approval-suggest.ts +12 -4
- package/src/bwrap/core.ts +39 -13
- package/src/bwrap/dcg-scan.ts +16 -6
- package/src/bwrap/holder.ts +3 -1
- package/src/bwrap/mihomo-config.ts +18 -6
- package/src/bwrap/network-stack.ts +69 -24
- package/src/bwrap/runtime.ts +30 -10
- package/src/bwrap/sandbox.ts +1 -1
- package/src/claude-code/common.ts +6 -2
- package/src/claude-code/edit-utils.ts +3 -1
- package/src/claude-code/files.ts +40 -14
- package/src/claude-code/glob.ts +12 -4
- package/src/claude-code/grep.ts +41 -14
- package/src/claude-code/session-tools.ts +18 -6
- package/src/claude-code/shell.ts +9 -3
- package/src/gh/base.ts +72 -24
- package/src/gh/tools/download-release-assets.ts +12 -4
- package/src/gh/tools/list-releases.ts +3 -1
- package/src/gh/tools/list-workflow-runs.ts +9 -3
- package/src/gh/tools/read-ci-logs.ts +21 -7
- package/src/gh/tools/read-repo.ts +3 -1
- package/src/lib/cli-args.ts +6 -2
- package/src/lib/cli.ts +48 -16
- package/src/lib/file-reads.ts +24 -8
- package/src/lib/github.ts +35 -12
- package/src/lib/lsp/adapter.ts +6 -2
- package/src/lib/lsp/bin.ts +12 -4
- package/src/lib/lsp/client.ts +205 -69
- package/src/lib/lsp/diagnostic.ts +6 -2
- package/src/lib/lsp/inspect-tool.ts +25 -9
- package/src/lib/lsp/lsp.ts +138 -46
- package/src/lib/lsp/rename-tool.ts +11 -4
- package/src/lib/lsp/rename.ts +25 -8
- package/src/lib/lsp/server-config.ts +41 -14
- package/src/lib/lsp/watcher.ts +51 -17
- package/src/lib/path.ts +18 -6
- package/src/lib/proc.ts +3 -1
- package/src/lib/proxy.ts +9 -3
- package/src/lib/request-policy.ts +3 -1
- package/src/lib/seq-state.ts +3 -1
- package/src/lib/ui.ts +23 -8
- package/src/lib/write-guard.ts +21 -7
- package/src/openai-cost/config.ts +3 -1
- package/src/openai-cost/cost.ts +54 -18
- package/src/openai-cost/index.ts +3 -1
- package/src/openai-cost/provider.ts +6 -2
- package/src/opencode/bash.ts +9 -3
- package/src/opencode/edit-engine.ts +24 -8
- package/src/opencode/files.ts +30 -10
- package/src/opencode/glob.ts +3 -1
- package/src/opencode/grep.ts +12 -4
- package/src/opencode/question.ts +3 -1
- package/src/opencode/ripgrep.ts +18 -6
- package/src/opencode/todo.ts +3 -1
- package/src/session-name.ts +39 -13
- package/src/spawn-agent-agents.ts +18 -6
- package/src/spawn-agent.ts +53 -18
- package/src/system-prompt/index.ts +9 -3
- package/src/talk/core.ts +143 -48
- package/src/talk/format.ts +12 -4
- package/src/talk/group.ts +9 -3
- package/src/talk/index.ts +30 -10
- package/src/talk/mailbox.ts +30 -10
- package/src/talk/policy.ts +6 -2
- package/src/talk/registry.ts +33 -11
- package/src/talk/storage.ts +3 -1
- package/src/vision-agent.ts +41 -14
- package/src/web/config.ts +3 -1
- package/src/web/fetch.ts +75 -25
- 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)
|
|
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
|
-
/**
|
|
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)
|
|
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)
|
|
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)
|
|
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"))
|
|
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)
|
|
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
|
-
//
|
|
255
|
+
// holder 退出触发内核清理 pid ns 内全部进程;
|
|
231
256
|
// slirp4netns 在宿主侧持有 tap fd,单独终止
|
|
232
|
-
|
|
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
|
|
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)
|
|
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)
|
|
444
|
+
if (settled) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
413
447
|
settled = true;
|
|
414
|
-
if (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
|
|
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
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
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
|
|
451
|
-
//
|
|
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
|
-
|
|
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})`, {
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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)
|
|
290
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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> = {};
|
package/src/bwrap/sandbox.ts
CHANGED
|
@@ -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
|
-
//
|
|
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)
|
|
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)
|
|
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"))
|
|
15
|
+
if (!content.includes("\t")) {
|
|
16
|
+
return content;
|
|
17
|
+
}
|
|
16
18
|
return content.replaceAll(/^\t+/gm, (tabs) => " ".repeat(tabs.length));
|
|
17
19
|
}
|
package/src/claude-code/files.ts
CHANGED
|
@@ -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)
|
|
48
|
-
|
|
49
|
-
|
|
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())
|
|
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)
|
|
93
|
-
|
|
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 === "")
|
|
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 === "")
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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);
|
package/src/claude-code/glob.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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)
|
|
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())
|
|
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));
|
package/src/claude-code/grep.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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)
|
|
111
|
-
|
|
112
|
-
|
|
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)
|
|
119
|
-
|
|
120
|
-
|
|
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)
|
|
148
|
+
if (globPattern) {
|
|
149
|
+
args.push("--glob", globPattern);
|
|
150
|
+
}
|
|
133
151
|
}
|
|
134
152
|
}
|
|
135
153
|
// 以 - 开头的 pattern 用 -e 显式声明,防止被 rg 当作选项
|
|
136
|
-
if (params.pattern.startsWith("-"))
|
|
137
|
-
|
|
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)
|
|
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)
|
|
212
|
-
|
|
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
|
|