@trim21/personal-pi-extensions 0.1.562 → 0.1.570
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 +4 -4
- package/src/bwrap/runtime.ts +18 -8
- package/src/claude-code/files.ts +38 -8
- package/src/claude-code/index.ts +9 -4
- package/src/claude-code/shell.ts +2 -1
- package/src/lib/lsp/client.ts +167 -45
- package/src/lib/lsp/lsp.ts +11 -1
- package/src/lib/lsp/rename-tool.ts +23 -13
- package/src/lib/request-policy.ts +25 -8
- package/src/lib/write-guard.ts +22 -6
- package/src/opencode/bash.ts +2 -1
- package/src/opencode/files.ts +43 -10
- package/src/opencode/index.ts +7 -2
- package/src/opencode/question.ts +18 -5
- package/src/spawn-agent.ts +2 -1
- package/src/talk/core.ts +43 -6
- package/src/talk/index.ts +10 -1
- package/src/web/fetch.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.570",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"format": "prettier --write .",
|
|
25
25
|
"test": "vitest run",
|
|
26
26
|
"test:coverage": "vitest run --coverage",
|
|
27
|
-
"sandbox": "
|
|
28
|
-
"aft-search": "
|
|
27
|
+
"sandbox": "jiti bin/sandbox.ts",
|
|
28
|
+
"aft-search": "jiti bin/aft-search.ts",
|
|
29
29
|
"build:holder": "esbuild src/bwrap/holder.ts --format=esm --target=node24 --outfile=src/bwrap/holder.js",
|
|
30
30
|
"prepare": "husky"
|
|
31
31
|
},
|
|
@@ -56,10 +56,10 @@
|
|
|
56
56
|
"eslint-plugin-unicorn": "74.0.0",
|
|
57
57
|
"eslint-plugin-unused-imports": "4.4.1",
|
|
58
58
|
"husky": "^9.1.7",
|
|
59
|
+
"jiti": "^2.7.0",
|
|
59
60
|
"lint-staged": "^17.3.0",
|
|
60
61
|
"prettier": "^3.9.6",
|
|
61
62
|
"shlex": "^3.0.0",
|
|
62
|
-
"tsx": "^4.23.12",
|
|
63
63
|
"typebox": "1.3.1",
|
|
64
64
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
65
65
|
"typescript-eslint": "^8.67.0",
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { type TObject, Type } from "typebox";
|
|
|
18
18
|
import { type CommandSpec, parseCommand } from "../lib/cli.js";
|
|
19
19
|
import { fenceCodeBlock } from "../lib/markdown.js";
|
|
20
20
|
import { formatDisplayPath } from "../lib/path.js";
|
|
21
|
-
import {
|
|
21
|
+
import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
|
|
22
22
|
import { type SelectAction, selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
|
|
23
23
|
import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-rules.js";
|
|
24
24
|
import { commandPatternsFor } from "./approval-suggest.js";
|
|
@@ -320,6 +320,15 @@ export class BwrapRuntime {
|
|
|
320
320
|
private bwrapUnavailable = false;
|
|
321
321
|
/** net-allowlist 首次执行时解析一次 mihomo 路径,之后随 runtime 复用,不逐命令扫描 PATH。 */
|
|
322
322
|
private mihomoPath: string | undefined;
|
|
323
|
+
/**
|
|
324
|
+
* 非沙盒请求策略由创建方注入:聚合入口把它与文件工具的 write-guard 共享同一实例,
|
|
325
|
+
* 独立入口各持一份、经 pi.events 同步。
|
|
326
|
+
*/
|
|
327
|
+
private readonly policy: RequestPolicy;
|
|
328
|
+
|
|
329
|
+
constructor(policy: RequestPolicy) {
|
|
330
|
+
this.policy = policy;
|
|
331
|
+
}
|
|
323
332
|
|
|
324
333
|
setup(pi: ExtensionAPI): void {
|
|
325
334
|
pi.registerFlag("no-bwrap", {
|
|
@@ -332,7 +341,7 @@ export class BwrapRuntime {
|
|
|
332
341
|
this.sandboxDisabled = pi.getFlag("no-bwrap") === true && ctx.hasUI;
|
|
333
342
|
this.resolved = undefined;
|
|
334
343
|
this.bwrapUnavailable = false;
|
|
335
|
-
|
|
344
|
+
this.policy.setDenyRequests(false);
|
|
336
345
|
if (process.platform === "win32") {
|
|
337
346
|
// Windows 没有 bubblewrap:不做 bwrap 检测、不显示 bwrap 状态,
|
|
338
347
|
// 每条 bash 命令在 execute 时逐条人工审批,模型无需知道 bwrap 的存在。
|
|
@@ -382,7 +391,7 @@ export class BwrapRuntime {
|
|
|
382
391
|
? " bwrap is unavailable (binary not found): bash commands are refused unless the user explicitly approves unsandboxed execution."
|
|
383
392
|
: "";
|
|
384
393
|
const denyRequestsText =
|
|
385
|
-
!isWindows &&
|
|
394
|
+
!isWindows && this.policy.deniesRequests()
|
|
386
395
|
? " Unsandboxed execution is currently denied by the user: `dangerouslyDisableSandbox` and writes outside the workspace are refused without approval."
|
|
387
396
|
: "";
|
|
388
397
|
return {
|
|
@@ -433,7 +442,7 @@ export class BwrapRuntime {
|
|
|
433
442
|
if (needsApproval && runtime.bwrapEnabled) {
|
|
434
443
|
// /bwrap-deny-request:非沙盒请求直接拒绝——审批规则与审批框都不再参与,
|
|
435
444
|
// 拒绝文案与用户点 Deny 相同,直到用户用 /bwrap-allow-request 恢复审批。
|
|
436
|
-
if (request.requestFullAccess === true &&
|
|
445
|
+
if (request.requestFullAccess === true && this.policy.deniesRequests()) {
|
|
437
446
|
throw new Error(UNSANDBOXED_DENIED);
|
|
438
447
|
}
|
|
439
448
|
// 先按 approvalRules 自动判定:allow 直接放行,deny 直接拒绝,未命中才弹框
|
|
@@ -879,7 +888,7 @@ export class BwrapRuntime {
|
|
|
879
888
|
|
|
880
889
|
/** 状态行文案:模式 + 非沙盒请求策略(拒绝时标注,便于解释模型的请求为何被拒)。 */
|
|
881
890
|
private statusLabel(mode: BwrapMode): string {
|
|
882
|
-
return `bwrap: ${mode}${
|
|
891
|
+
return `bwrap: ${mode}${this.policy.deniesRequests() ? " (requests denied)" : ""}`;
|
|
883
892
|
}
|
|
884
893
|
|
|
885
894
|
/**
|
|
@@ -887,7 +896,7 @@ export class BwrapRuntime {
|
|
|
887
896
|
* 编辑类工具的工作区外写入)直接按用户拒绝处理,不再弹审批框。
|
|
888
897
|
*/
|
|
889
898
|
private setDenyRequests(deny: boolean, ctx: ExtensionCommandContext): void {
|
|
890
|
-
|
|
899
|
+
this.policy.setDenyRequests(deny);
|
|
891
900
|
ctx.ui.notify(
|
|
892
901
|
deny
|
|
893
902
|
? "Non-sandbox requests are denied without approval; /bwrap-allow-request restores approval."
|
|
@@ -916,6 +925,7 @@ export class BwrapRuntime {
|
|
|
916
925
|
}
|
|
917
926
|
}
|
|
918
927
|
|
|
919
|
-
|
|
920
|
-
|
|
928
|
+
/** 缺省自建一份不跨入口同步的策略;入口通常显式传入 `createRequestPolicy(pi.events)`。 */
|
|
929
|
+
export function createBwrapRuntime(policy: RequestPolicy = createRequestPolicy()): BwrapRuntime {
|
|
930
|
+
return new BwrapRuntime(policy);
|
|
921
931
|
}
|
package/src/claude-code/files.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { createLspManager, type LspService, type LspServiceOptions } from "../li
|
|
|
20
20
|
import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
|
|
21
21
|
import { formatSubtitlePath } from "../lib/path.js";
|
|
22
22
|
import type { ToolPendant } from "../lib/pendant.ts";
|
|
23
|
+
import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
|
|
23
24
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
24
25
|
import {
|
|
25
26
|
type ClaudeCodeState,
|
|
@@ -119,9 +120,10 @@ function splitFileLines(content: string): string[] {
|
|
|
119
120
|
* 易误判多一层缩进。limit 未指定时读取全部。无 PARTIAL 提示、无单行截断
|
|
120
121
|
* (由 execute 层的字节/token 上限兜底)。
|
|
121
122
|
*
|
|
122
|
-
* offset 为负数时从文件末尾倒数(`offset=-5` 等价 `tail -n 5
|
|
123
|
-
*
|
|
124
|
-
* offset=0
|
|
123
|
+
* offset 为负数时从文件末尾倒数(`offset=-5` 等价 `tail -n 5`)——倒数按**真实
|
|
124
|
+
* 行**计算,splitFileLines 补出的尾随幻影行不算一行也不在输出里;超出文件长度时
|
|
125
|
+
* 钳到文件开头(与 tail 一致,不告警)。输出行号始终为绝对行号;offset=0 的
|
|
126
|
+
* 「行号从 0 起」是对齐 Claude Code lineOffset 的历史语义。
|
|
125
127
|
*/
|
|
126
128
|
export function formatReadOutput(
|
|
127
129
|
content: string,
|
|
@@ -142,10 +144,18 @@ export function formatReadOutput(
|
|
|
142
144
|
totalLines,
|
|
143
145
|
};
|
|
144
146
|
}
|
|
145
|
-
|
|
147
|
+
// 尾随幻影行(splitFileLines 补的那个 \n)不对应文件内容:负 offset 按真实行
|
|
148
|
+
// 从末尾倒数,窗口也不含它,否则 `offset=-1` 只会读到一个空行。
|
|
149
|
+
const realLines = totalLines - 1;
|
|
150
|
+
const fromTail = offset < 0;
|
|
151
|
+
const startIndex = fromTail ? Math.max(realLines + offset, 0) : offset === 0 ? 0 : offset - 1;
|
|
146
152
|
const firstLine = offset === 0 ? 0 : startIndex + 1;
|
|
147
|
-
const
|
|
148
|
-
limit === undefined ?
|
|
153
|
+
const endIndex = fromTail
|
|
154
|
+
? Math.min(limit === undefined ? realLines : startIndex + limit, realLines)
|
|
155
|
+
: limit === undefined
|
|
156
|
+
? totalLines
|
|
157
|
+
: startIndex + limit;
|
|
158
|
+
const selected = lines.slice(startIndex, endIndex);
|
|
149
159
|
const text = selected.map((line, index) => `${firstLine + index}: ${line}`).join("\n");
|
|
150
160
|
return { text, totalLines };
|
|
151
161
|
}
|
|
@@ -230,6 +240,7 @@ export function registerFileTools(
|
|
|
230
240
|
pi: ExtensionAPI,
|
|
231
241
|
state: ClaudeCodeState,
|
|
232
242
|
getService: () => LspService,
|
|
243
|
+
policy: RequestPolicy,
|
|
233
244
|
): void {
|
|
234
245
|
pi.registerTool({
|
|
235
246
|
name: "Read",
|
|
@@ -340,6 +351,7 @@ export function registerFileTools(
|
|
|
340
351
|
// 与 Edit / Write 同一条驻留路径:didOpen 后等待该文件的诊断并报告
|
|
341
352
|
const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
342
353
|
notify: (message, level) => ctx.ui.notify(message, level),
|
|
354
|
+
signal,
|
|
343
355
|
});
|
|
344
356
|
return {
|
|
345
357
|
content: [
|
|
@@ -399,6 +411,8 @@ export function registerFileTools(
|
|
|
399
411
|
newText: params.new_string,
|
|
400
412
|
replaceAll: params.replace_all,
|
|
401
413
|
},
|
|
414
|
+
policy,
|
|
415
|
+
signal,
|
|
402
416
|
});
|
|
403
417
|
const [message, details, diagnostics] = await withFileMutationQueue<
|
|
404
418
|
[string, FileToolDetails, DiagnosticReport]
|
|
@@ -532,6 +546,7 @@ export function registerFileTools(
|
|
|
532
546
|
signal?.throwIfAborted();
|
|
533
547
|
const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
534
548
|
notify: (message, level) => ctx.ui.notify(message, level),
|
|
549
|
+
signal,
|
|
535
550
|
});
|
|
536
551
|
return [
|
|
537
552
|
text,
|
|
@@ -594,6 +609,8 @@ export function registerFileTools(
|
|
|
594
609
|
toolName: "Write",
|
|
595
610
|
absolutePath: filePath,
|
|
596
611
|
change: { oldText: "", newText: params.content },
|
|
612
|
+
policy,
|
|
613
|
+
signal,
|
|
597
614
|
});
|
|
598
615
|
const [message, details, diagnostics] = await withFileMutationQueue<
|
|
599
616
|
[string, FileToolDetails, DiagnosticReport]
|
|
@@ -628,6 +645,7 @@ export function registerFileTools(
|
|
|
628
645
|
signal?.throwIfAborted();
|
|
629
646
|
const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
630
647
|
notify: (message, level) => ctx.ui.notify(message, level),
|
|
648
|
+
signal,
|
|
631
649
|
});
|
|
632
650
|
return [
|
|
633
651
|
text,
|
|
@@ -686,14 +704,25 @@ function restoreFileReads(
|
|
|
686
704
|
}
|
|
687
705
|
}
|
|
688
706
|
|
|
707
|
+
export interface ClaudeCodeFileToolOptions extends LspServiceOptions {
|
|
708
|
+
/** 与 bash runtime 共享的非沙盒请求策略;独立入口不传,自建一份。 */
|
|
709
|
+
policy?: RequestPolicy;
|
|
710
|
+
}
|
|
711
|
+
|
|
689
712
|
/**
|
|
690
713
|
* 独立扩展入口:files.ts 可单独经 `-e claude-code/files.ts` 加载(spawn-agent
|
|
691
714
|
* 子代理把 Read/Edit/Write 工具名映射到本文件),无需经 index.ts。reads
|
|
692
715
|
* state 归本文件所有:扩展实例内创建,并随 session 事件从历史分支恢复,
|
|
693
716
|
* 与主进程 index.ts 聚合加载时的行为一致。
|
|
694
717
|
*/
|
|
695
|
-
export default function claudeCodeFileTools(
|
|
718
|
+
export default function claudeCodeFileTools(
|
|
719
|
+
pi: ExtensionAPI,
|
|
720
|
+
options?: ClaudeCodeFileToolOptions,
|
|
721
|
+
): void {
|
|
696
722
|
const state = createClaudeCodeState();
|
|
723
|
+
// 聚合入口(index.ts)注入与 bash runtime 共享的那一份;独立入口(spawn-agent
|
|
724
|
+
// 按工具名 `-e` 加载本文件)自建一份,靠 pi.events 跟随同一开关。
|
|
725
|
+
const policy = options?.policy ?? createRequestPolicy(pi.events);
|
|
697
726
|
|
|
698
727
|
// LSP 专属工具(lsp-rename / inspect 族)仅在 lsp.json 存在 enabled 服务器时
|
|
699
728
|
// 注册(session_start 校验后);本工具集跟踪 read-before-write 状态,rename
|
|
@@ -703,6 +732,7 @@ export default function claudeCodeFileTools(pi: ExtensionAPI, options?: LspServi
|
|
|
703
732
|
{
|
|
704
733
|
onEnabled: (pi, service) => {
|
|
705
734
|
registerLspRenameTool(pi, service, {
|
|
735
|
+
policy,
|
|
706
736
|
recordReads: async (applied) => {
|
|
707
737
|
const reads: Record<string, FileSnapshot> = {};
|
|
708
738
|
for (const fileEdit of applied) {
|
|
@@ -736,5 +766,5 @@ export default function claudeCodeFileTools(pi: ExtensionAPI, options?: LspServi
|
|
|
736
766
|
});
|
|
737
767
|
|
|
738
768
|
// 文件工具无条件注册;service 惰性获取,disabled 时为 no-op。
|
|
739
|
-
registerFileTools(pi, state, () => manager.mustLazyGetService());
|
|
769
|
+
registerFileTools(pi, state, () => manager.mustLazyGetService(), policy);
|
|
740
770
|
}
|
package/src/claude-code/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
|
+
import { createBwrapRuntime } from "../bwrap/runtime.js";
|
|
4
|
+
import { createRequestPolicy } from "../lib/request-policy.js";
|
|
3
5
|
import claudeCodeFileTools from "./files.js";
|
|
4
6
|
import { registerSearchTools } from "./search.js";
|
|
5
7
|
import { registerSessionTools } from "./session-tools.js";
|
|
@@ -9,12 +11,15 @@ import { registerShellTools } from "./shell.js";
|
|
|
9
11
|
* Claude Code 风格工具集入口:聚合 files / search / shell / session 四组工具。
|
|
10
12
|
*
|
|
11
13
|
* reads state 的创建与 session 恢复归 files.ts 所有(见其 default export),
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* 非沙盒请求策略(Read/Edit/Write/lsp-rename 的工作区外写入与 Bash 的
|
|
15
|
+
* `dangerouslyDisableSandbox` 共用)由本文件创建后注入两侧;spawn-agent
|
|
16
|
+
* 子代理按工具名直接 `-e` 加载各模块文件(files.ts / grep.ts / glob.ts 均为
|
|
17
|
+
* 独立扩展入口),那些入口各自创建一份策略并经 pi.events 保持同步。
|
|
14
18
|
*/
|
|
15
19
|
export default function claudeCodeTools(pi: ExtensionAPI): void {
|
|
16
|
-
|
|
20
|
+
const policy = createRequestPolicy(pi.events);
|
|
21
|
+
claudeCodeFileTools(pi, { policy });
|
|
17
22
|
registerSearchTools(pi);
|
|
18
|
-
registerShellTools(pi);
|
|
23
|
+
registerShellTools(pi, createBwrapRuntime(policy));
|
|
19
24
|
registerSessionTools(pi);
|
|
20
25
|
}
|
package/src/claude-code/shell.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
sandboxHintBlock,
|
|
19
19
|
} from "../bwrap/runtime.js";
|
|
20
20
|
import { resolveWorkdir } from "../lib/path.js";
|
|
21
|
+
import { createRequestPolicy } from "../lib/request-policy.js";
|
|
21
22
|
|
|
22
23
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
23
24
|
const MAX_TIMEOUT_MS = 7_200_000;
|
|
@@ -85,7 +86,7 @@ export function formatBashSuccess(result: Awaited<ReturnType<BwrapRuntime["execu
|
|
|
85
86
|
*/
|
|
86
87
|
export function registerShellTools(
|
|
87
88
|
pi: ExtensionAPI,
|
|
88
|
-
runtime: BwrapRuntime = createBwrapRuntime(),
|
|
89
|
+
runtime: BwrapRuntime = createBwrapRuntime(createRequestPolicy(pi.events)),
|
|
89
90
|
): void {
|
|
90
91
|
runtime.setup(pi);
|
|
91
92
|
pi.registerTool({
|
package/src/lib/lsp/client.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { extname, isAbsolute, normalize, resolve } from "node:path";
|
|
|
14
14
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
+
CancellationTokenSource,
|
|
17
18
|
createMessageConnection,
|
|
18
19
|
type MessageConnection,
|
|
19
20
|
ResponseError,
|
|
@@ -161,6 +162,8 @@ export interface RenameSymbolRequest {
|
|
|
161
162
|
line: number;
|
|
162
163
|
character: number;
|
|
163
164
|
newName: string;
|
|
165
|
+
/** 调用方取消信号:中止时立刻放弃等待并通知服务器取消。 */
|
|
166
|
+
signal?: AbortSignal;
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
export interface RenameSymbolResult {
|
|
@@ -174,6 +177,8 @@ export interface InspectPositionRequest {
|
|
|
174
177
|
path: string;
|
|
175
178
|
line: number;
|
|
176
179
|
character: number;
|
|
180
|
+
/** 调用方取消信号:中止时立刻放弃等待并通知服务器取消。 */
|
|
181
|
+
signal?: AbortSignal;
|
|
177
182
|
}
|
|
178
183
|
|
|
179
184
|
/** definition / references 归一化后的位置(0-based;1-based 格式化由工具层负责)。 */
|
|
@@ -433,6 +438,31 @@ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
|
433
438
|
|
|
434
439
|
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
435
440
|
|
|
441
|
+
/** 中止请求时的拒绝原因:沿用 signal.reason(默认是 name=AbortError 的 DOMException)。 */
|
|
442
|
+
function abortReason(signal: AbortSignal): Error {
|
|
443
|
+
return signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** 可中止的 sleep:中止时以 signal.reason 拒绝,而不是白等到轮询间隔结束。 */
|
|
447
|
+
function sleepWithSignal(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
448
|
+
if (!signal) return sleep(ms);
|
|
449
|
+
return new Promise<void>((resolve, reject) => {
|
|
450
|
+
const onAbort = (): void => {
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
reject(abortReason(signal));
|
|
453
|
+
};
|
|
454
|
+
const timer = setTimeout(() => {
|
|
455
|
+
signal.removeEventListener("abort", onAbort);
|
|
456
|
+
resolve();
|
|
457
|
+
}, ms);
|
|
458
|
+
if (signal.aborted) {
|
|
459
|
+
onAbort();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
436
466
|
/** pull 诊断失败后的重试间隔。 */
|
|
437
467
|
const PULL_RETRY_INTERVAL_MS = 100;
|
|
438
468
|
|
|
@@ -457,6 +487,37 @@ function stopProcess(process: LspServerHandle["process"]): Promise<void> {
|
|
|
457
487
|
});
|
|
458
488
|
}
|
|
459
489
|
|
|
490
|
+
/**
|
|
491
|
+
* 驻留文档超容量时的淘汰计划(纯函数,见 create 里的 evictExcess):
|
|
492
|
+
* - `stale`:lruOrder 里已不在 files 的陈旧 key(被 watchedFiles 的 didClose 移出),
|
|
493
|
+
* 不占容量,直接回收;
|
|
494
|
+
* - `evict`:按使用顺序(最久未用在前)应 didClose 的路径。
|
|
495
|
+
*
|
|
496
|
+
* 容量是**尽力而为**的上限:正在等诊断的文档必须留到诊断收集完(关闭会抹掉
|
|
497
|
+
* 本次写入的诊断结果),因此可淘汰项不足时宁可少淘汰几个(甚至一个都不淘汰),
|
|
498
|
+
* 把收敛留给后续 openDocument。调用方的淘汰循环必须单次有界——等待窗口内的文档
|
|
499
|
+
* 若被当成"稍后重试"的对象,就是同步自旋(那部分代码里没有 await),会把事件
|
|
500
|
+
* 循环整个锁死。
|
|
501
|
+
*/
|
|
502
|
+
export function evictionPlan(options: {
|
|
503
|
+
/** lruOrder 的迭代序:最久未用在前。 */
|
|
504
|
+
order: readonly string[];
|
|
505
|
+
/** 该路径是否仍在驻留集合(files)里。 */
|
|
506
|
+
isResident: (path: string) => boolean;
|
|
507
|
+
/** 该路径是否正在等诊断。 */
|
|
508
|
+
isWaiting: (path: string) => boolean;
|
|
509
|
+
maxOpenDocuments: number;
|
|
510
|
+
}): { stale: string[]; evict: string[] } {
|
|
511
|
+
const stale: string[] = [];
|
|
512
|
+
const evictable: string[] = [];
|
|
513
|
+
for (const path of options.order) {
|
|
514
|
+
if (!options.isResident(path)) stale.push(path);
|
|
515
|
+
else if (!options.isWaiting(path)) evictable.push(path);
|
|
516
|
+
}
|
|
517
|
+
const excess = options.order.length - stale.length - options.maxOpenDocuments;
|
|
518
|
+
return { stale, evict: excess > 0 ? evictable.slice(0, excess) : [] };
|
|
519
|
+
}
|
|
520
|
+
|
|
460
521
|
export async function create(input: CreateInput): Promise<LspClient> {
|
|
461
522
|
const diagnosticsDebounceMs = input.diagnosticsDebounceMs ?? clientDefaults.diagnosticsDebounceMs;
|
|
462
523
|
const diagnosticsDocumentWaitTimeoutMs =
|
|
@@ -655,24 +716,29 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
655
716
|
lruOrder.set(path, Date.now());
|
|
656
717
|
};
|
|
657
718
|
|
|
658
|
-
/**
|
|
719
|
+
/**
|
|
720
|
+
* 超过容量时淘汰最久未使用的文档(didClose 并移出驻留集合)。
|
|
721
|
+
*
|
|
722
|
+
* 单次有界遍历:容量是尽力而为的上限——等待诊断的文档要留到诊断收集完,可淘汰
|
|
723
|
+
* 项不足时就少淘汰(见 evictionPlan)。不要写成"跳过等待项再重试"的循环:等待
|
|
724
|
+
* 窗口内的文档可能占满驻留集合(例如某路径的等待尚未结束时它又被重新打开),
|
|
725
|
+
* 那时重试分支没有 await,等于同步自旋。
|
|
726
|
+
*/
|
|
659
727
|
async function evictExcess(): Promise<void> {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
728
|
+
const plan = evictionPlan({
|
|
729
|
+
order: [...lruOrder.keys()],
|
|
730
|
+
isResident: (path) => files[path] !== undefined,
|
|
731
|
+
isWaiting: (path) => waitingForDiagnostics.has(path),
|
|
732
|
+
maxOpenDocuments,
|
|
733
|
+
});
|
|
734
|
+
for (const path of plan.stale) lruOrder.delete(path);
|
|
735
|
+
for (const path of plan.evict) {
|
|
736
|
+
lruOrder.delete(path);
|
|
671
737
|
await connection.sendNotification("textDocument/didClose", {
|
|
672
|
-
textDocument: { uri: pathToFileURL(
|
|
738
|
+
textDocument: { uri: pathToFileURL(path).href },
|
|
673
739
|
});
|
|
674
|
-
delete files[
|
|
675
|
-
documentVersions.delete(
|
|
740
|
+
delete files[path];
|
|
741
|
+
documentVersions.delete(path);
|
|
676
742
|
}
|
|
677
743
|
}
|
|
678
744
|
|
|
@@ -1073,9 +1139,49 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1073
1139
|
};
|
|
1074
1140
|
};
|
|
1075
1141
|
|
|
1076
|
-
|
|
1142
|
+
/**
|
|
1143
|
+
* 发一次可取消的请求:signal 中止时既通过 CancellationToken 让服务器停下
|
|
1144
|
+
* ($/cancelRequest),也立刻以 signal.reason(默认 AbortError)拒绝本地
|
|
1145
|
+
* promise——服务器可能永远不回应,工具调用不能就这么挂着。
|
|
1146
|
+
*/
|
|
1147
|
+
async function sendAbortableRequest<T>(
|
|
1148
|
+
method: string,
|
|
1149
|
+
params: object,
|
|
1150
|
+
signal: AbortSignal | undefined,
|
|
1151
|
+
): Promise<T> {
|
|
1152
|
+
if (!signal) return await connection.sendRequest<T>(method, params);
|
|
1153
|
+
if (signal.aborted) throw abortReason(signal);
|
|
1154
|
+
const source = new CancellationTokenSource();
|
|
1155
|
+
const aborted = Promise.withResolvers<never>();
|
|
1156
|
+
// 中止可能恰好在请求已返回之后才触发:那时 race 已经结算,而这个 promise 的
|
|
1157
|
+
// rejection 没人再看——提前挂一个空 catch,否则它会变成 unhandled rejection
|
|
1158
|
+
// (pi 会因此整体退出)。
|
|
1159
|
+
void aborted.promise.catch(() => {
|
|
1160
|
+
/* 失败路径由 race 处理 */
|
|
1161
|
+
});
|
|
1162
|
+
const onAbort = (): void => {
|
|
1163
|
+
source.cancel();
|
|
1164
|
+
aborted.reject(abortReason(signal));
|
|
1165
|
+
};
|
|
1166
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1077
1167
|
try {
|
|
1078
|
-
return await
|
|
1168
|
+
return await Promise.race([
|
|
1169
|
+
connection.sendRequest<T>(method, params, source.token),
|
|
1170
|
+
aborted.promise,
|
|
1171
|
+
]);
|
|
1172
|
+
} finally {
|
|
1173
|
+
signal.removeEventListener("abort", onAbort);
|
|
1174
|
+
source.dispose();
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const sendInspectRequest = async <T>(
|
|
1179
|
+
method: string,
|
|
1180
|
+
message: object,
|
|
1181
|
+
signal?: AbortSignal,
|
|
1182
|
+
): Promise<T> => {
|
|
1183
|
+
try {
|
|
1184
|
+
return await retryOnContentModified(() => sendAbortableRequest<T>(method, message, signal));
|
|
1079
1185
|
} catch (error) {
|
|
1080
1186
|
if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) {
|
|
1081
1187
|
throw new LspMethodNotSupportedError(input.serverID, method);
|
|
@@ -1170,11 +1276,15 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1170
1276
|
// 与编辑器行为一致。
|
|
1171
1277
|
const referencesRequest = () =>
|
|
1172
1278
|
retryOnContentModified(() =>
|
|
1173
|
-
|
|
1174
|
-
textDocument
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1279
|
+
sendAbortableRequest<{ uri: string }[] | null>(
|
|
1280
|
+
"textDocument/references",
|
|
1281
|
+
{
|
|
1282
|
+
textDocument: { uri },
|
|
1283
|
+
position,
|
|
1284
|
+
context: { includeDeclaration: true },
|
|
1285
|
+
},
|
|
1286
|
+
request.signal,
|
|
1287
|
+
),
|
|
1178
1288
|
);
|
|
1179
1289
|
|
|
1180
1290
|
const toPaths = (locations: { uri: string }[] | null): Set<string> =>
|
|
@@ -1187,11 +1297,15 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1187
1297
|
const sendRename = async (): Promise<WorkspaceEdit | null> => {
|
|
1188
1298
|
try {
|
|
1189
1299
|
return await retryOnContentModified(() =>
|
|
1190
|
-
|
|
1191
|
-
textDocument
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1300
|
+
sendAbortableRequest<WorkspaceEdit | null>(
|
|
1301
|
+
"textDocument/rename",
|
|
1302
|
+
{
|
|
1303
|
+
textDocument: { uri },
|
|
1304
|
+
position,
|
|
1305
|
+
newName: request.newName,
|
|
1306
|
+
},
|
|
1307
|
+
request.signal,
|
|
1308
|
+
),
|
|
1195
1309
|
);
|
|
1196
1310
|
} catch (error) {
|
|
1197
1311
|
if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) {
|
|
@@ -1206,10 +1320,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1206
1320
|
let prepared: PrepareRenameResponse | null;
|
|
1207
1321
|
try {
|
|
1208
1322
|
prepared = await retryOnContentModified(() =>
|
|
1209
|
-
|
|
1210
|
-
textDocument
|
|
1211
|
-
position,
|
|
1212
|
-
|
|
1323
|
+
sendAbortableRequest<PrepareRenameResponse | null>(
|
|
1324
|
+
"textDocument/prepareRename",
|
|
1325
|
+
{ textDocument: { uri }, position },
|
|
1326
|
+
request.signal,
|
|
1327
|
+
),
|
|
1213
1328
|
);
|
|
1214
1329
|
} catch (error) {
|
|
1215
1330
|
if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) {
|
|
@@ -1235,6 +1350,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1235
1350
|
let previous: Set<string> | undefined;
|
|
1236
1351
|
let current = toPaths(locations);
|
|
1237
1352
|
for (;;) {
|
|
1353
|
+
request.signal?.throwIfAborted();
|
|
1238
1354
|
const settled = previous !== undefined && samePaths(previous, current);
|
|
1239
1355
|
const expired = Date.now() >= deadline;
|
|
1240
1356
|
if (settled || expired) {
|
|
@@ -1259,34 +1375,40 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1259
1375
|
// 继续轮询到重新收敛后再重发 rename 复检(预算耗尽则向上抛)。
|
|
1260
1376
|
}
|
|
1261
1377
|
previous = current;
|
|
1262
|
-
await
|
|
1378
|
+
await sleepWithSignal(renameVerificationTiming.pollMs, request.signal);
|
|
1263
1379
|
current = toPaths(await referencesRequest());
|
|
1264
1380
|
}
|
|
1265
1381
|
},
|
|
1266
1382
|
async definition(request: InspectPositionRequest): Promise<InspectLocation[]> {
|
|
1267
1383
|
const { uri, position } = await preparePositionRequest(request);
|
|
1268
1384
|
return toInspectLocations(
|
|
1269
|
-
await sendInspectRequest<DefinitionResult>(
|
|
1270
|
-
textDocument
|
|
1271
|
-
position,
|
|
1272
|
-
|
|
1385
|
+
await sendInspectRequest<DefinitionResult>(
|
|
1386
|
+
"textDocument/definition",
|
|
1387
|
+
{ textDocument: { uri }, position },
|
|
1388
|
+
request.signal,
|
|
1389
|
+
),
|
|
1273
1390
|
);
|
|
1274
1391
|
},
|
|
1275
1392
|
async references(request: InspectPositionRequest): Promise<InspectLocation[]> {
|
|
1276
1393
|
const { uri, position } = await preparePositionRequest(request);
|
|
1277
|
-
const locations = await sendInspectRequest<LspLocation[] | null>(
|
|
1278
|
-
textDocument
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1394
|
+
const locations = await sendInspectRequest<LspLocation[] | null>(
|
|
1395
|
+
"textDocument/references",
|
|
1396
|
+
{
|
|
1397
|
+
textDocument: { uri },
|
|
1398
|
+
position,
|
|
1399
|
+
context: { includeDeclaration: true },
|
|
1400
|
+
},
|
|
1401
|
+
request.signal,
|
|
1402
|
+
);
|
|
1282
1403
|
return toInspectLocations(locations);
|
|
1283
1404
|
},
|
|
1284
1405
|
async hover(request: InspectPositionRequest): Promise<Hover | null> {
|
|
1285
1406
|
const { uri, position } = await preparePositionRequest(request);
|
|
1286
|
-
return await sendInspectRequest<Hover | null>(
|
|
1287
|
-
textDocument
|
|
1288
|
-
position,
|
|
1289
|
-
|
|
1407
|
+
return await sendInspectRequest<Hover | null>(
|
|
1408
|
+
"textDocument/hover",
|
|
1409
|
+
{ textDocument: { uri }, position },
|
|
1410
|
+
request.signal,
|
|
1411
|
+
);
|
|
1290
1412
|
},
|
|
1291
1413
|
get diagnostics() {
|
|
1292
1414
|
const result = new Map<string, Diagnostic[]>();
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -965,6 +965,7 @@ export function createLspService(
|
|
|
965
965
|
line: request.line,
|
|
966
966
|
character: request.character,
|
|
967
967
|
newName: request.newName,
|
|
968
|
+
signal: request.options?.signal,
|
|
968
969
|
});
|
|
969
970
|
return {
|
|
970
971
|
serverID: client.serverID,
|
|
@@ -972,6 +973,8 @@ export function createLspService(
|
|
|
972
973
|
...(result.placeholder !== undefined && { placeholder: result.placeholder }),
|
|
973
974
|
};
|
|
974
975
|
} catch (error) {
|
|
976
|
+
// 调用方取消不是"服务器无法重命名":直接向上抛
|
|
977
|
+
request.options?.signal?.throwIfAborted();
|
|
975
978
|
failures.push({ serverID: client.serverID, error });
|
|
976
979
|
}
|
|
977
980
|
}
|
|
@@ -1006,7 +1009,12 @@ export function createLspService(
|
|
|
1006
1009
|
}
|
|
1007
1010
|
const failures: { serverID: string; error: unknown }[] = [];
|
|
1008
1011
|
for (const client of clients) {
|
|
1009
|
-
const position = {
|
|
1012
|
+
const position = {
|
|
1013
|
+
path: request.file,
|
|
1014
|
+
line: request.line,
|
|
1015
|
+
character: request.character,
|
|
1016
|
+
signal: request.options?.signal,
|
|
1017
|
+
};
|
|
1010
1018
|
try {
|
|
1011
1019
|
if (request.query === "hover") {
|
|
1012
1020
|
const hover = await client.hover(position);
|
|
@@ -1022,6 +1030,8 @@ export function createLspService(
|
|
|
1022
1030
|
locations,
|
|
1023
1031
|
} as LspInspectResult<Q>;
|
|
1024
1032
|
} catch (error) {
|
|
1033
|
+
// 调用方取消不是"服务器失败":直接向上抛,别折叠成 all-servers-failed
|
|
1034
|
+
request.options?.signal?.throwIfAborted();
|
|
1025
1035
|
failures.push({ serverID: client.serverID, error });
|
|
1026
1036
|
}
|
|
1027
1037
|
}
|
|
@@ -23,6 +23,7 @@ import { Type } from "typebox";
|
|
|
23
23
|
|
|
24
24
|
import { resolvePathArg } from "../path.js";
|
|
25
25
|
import type { ToolPendant } from "../pendant.ts";
|
|
26
|
+
import type { RequestPolicy } from "../request-policy.js";
|
|
26
27
|
import { guardWriteAccess } from "../write-guard.js";
|
|
27
28
|
import { RenameNotPossibleError } from "./client.js";
|
|
28
29
|
import type { LspService } from "./lsp.js";
|
|
@@ -30,10 +31,13 @@ import {
|
|
|
30
31
|
type AppliedFileEdit,
|
|
31
32
|
canonicalizeEdit,
|
|
32
33
|
expandWorkspaceEdit,
|
|
34
|
+
type LspPosition,
|
|
33
35
|
symbolCandidates,
|
|
34
36
|
} from "./rename.js";
|
|
35
37
|
|
|
36
|
-
export interface
|
|
38
|
+
export interface LspRenameOptions {
|
|
39
|
+
/** 调用方所在扩展入口的非沙盒请求策略(与文件工具的 write-guard 同一份)。 */
|
|
40
|
+
policy: RequestPolicy;
|
|
37
41
|
/**
|
|
38
42
|
* rename 落盘后对每个被修改文件做已读记账;返回的 map 随 details.reads
|
|
39
43
|
* 持久化,供 session 恢复时重放。不跟踪已读状态的工具集不提供该 hook。
|
|
@@ -49,7 +53,7 @@ const LSP_RENAME_PROMPT = readFileSync(
|
|
|
49
53
|
export function registerLspRenameTool(
|
|
50
54
|
pi: ExtensionAPI,
|
|
51
55
|
service: LspService,
|
|
52
|
-
|
|
56
|
+
options: LspRenameOptions,
|
|
53
57
|
): void {
|
|
54
58
|
pi.registerTool({
|
|
55
59
|
name: "lsp-rename",
|
|
@@ -100,7 +104,7 @@ export function registerLspRenameTool(
|
|
|
100
104
|
|
|
101
105
|
const notify = (message: string, level: "info" | "warning" | "error") =>
|
|
102
106
|
ctx.ui.notify(message, level);
|
|
103
|
-
const
|
|
107
|
+
const requestOptions = { notify, signal };
|
|
104
108
|
|
|
105
109
|
// ── 按 symbol 名枚举行内候选,逐候选探测与消歧 ────────────────────────
|
|
106
110
|
const candidates = symbolCandidates(
|
|
@@ -114,7 +118,12 @@ export function registerLspRenameTool(
|
|
|
114
118
|
`Symbol "${params.symbol}" not found on line ${params.line} of ${filePath}. Read the file again and locate the symbol.`,
|
|
115
119
|
);
|
|
116
120
|
}
|
|
117
|
-
|
|
121
|
+
// 探测结果必须与候选成对记录:失败的候选不会进 successes,若按下标去取,
|
|
122
|
+
// 后面的成功结果会被错配给前面的候选——消歧提示就会指向别的符号的列号。
|
|
123
|
+
const successes: {
|
|
124
|
+
candidate: LspPosition;
|
|
125
|
+
result: Awaited<ReturnType<typeof service.rename>>;
|
|
126
|
+
}[] = [];
|
|
118
127
|
const notPossibleErrors: string[] = [];
|
|
119
128
|
for (const candidate of candidates) {
|
|
120
129
|
signal?.throwIfAborted();
|
|
@@ -125,9 +134,9 @@ export function registerLspRenameTool(
|
|
|
125
134
|
line: candidate.line,
|
|
126
135
|
character: candidate.character,
|
|
127
136
|
newName: params.new_name,
|
|
128
|
-
options,
|
|
137
|
+
options: requestOptions,
|
|
129
138
|
});
|
|
130
|
-
successes.push({ result });
|
|
139
|
+
successes.push({ candidate, result });
|
|
131
140
|
} catch (error) {
|
|
132
141
|
if (error instanceof RenameNotPossibleError) {
|
|
133
142
|
notPossibleErrors.push(error.message);
|
|
@@ -147,15 +156,13 @@ export function registerLspRenameTool(
|
|
|
147
156
|
// 同一符号的多次出现编辑集合一致;不一致即为不同符号 → 要求补 character
|
|
148
157
|
const groups = new Map<
|
|
149
158
|
string,
|
|
150
|
-
{ result: (typeof successes)[number]["result"]; candidates:
|
|
159
|
+
{ result: (typeof successes)[number]["result"]; candidates: LspPosition[] }
|
|
151
160
|
>();
|
|
152
|
-
for (const
|
|
153
|
-
const
|
|
154
|
-
if (!entry) continue;
|
|
155
|
-
const key = canonicalizeEdit(entry.result.edit);
|
|
161
|
+
for (const { candidate, result: probe } of successes) {
|
|
162
|
+
const key = canonicalizeEdit(probe.edit);
|
|
156
163
|
const group = groups.get(key);
|
|
157
164
|
if (group) group.candidates.push(candidate);
|
|
158
|
-
else groups.set(key, { result:
|
|
165
|
+
else groups.set(key, { result: probe, candidates: [candidate] });
|
|
159
166
|
}
|
|
160
167
|
if (groups.size > 1) {
|
|
161
168
|
const listing = [...groups.values()]
|
|
@@ -187,6 +194,8 @@ export function registerLspRenameTool(
|
|
|
187
194
|
toolName: "lsp-rename",
|
|
188
195
|
absolutePath: fileEdit.path,
|
|
189
196
|
change: { oldText: fileEdit.oldText, newText: fileEdit.newText },
|
|
197
|
+
policy: options.policy,
|
|
198
|
+
signal,
|
|
190
199
|
});
|
|
191
200
|
}
|
|
192
201
|
|
|
@@ -199,7 +208,8 @@ export function registerLspRenameTool(
|
|
|
199
208
|
`### ${fileEdit.path} (${fileEdit.changeCount} edit(s))\n\n\`\`\`diff\n${generateDiffString(fileEdit.oldText, fileEdit.newText).diff}\n\`\`\``,
|
|
200
209
|
);
|
|
201
210
|
}
|
|
202
|
-
const reads =
|
|
211
|
+
const reads =
|
|
212
|
+
options.recordReads === undefined ? undefined : await options.recordReads(applied);
|
|
203
213
|
|
|
204
214
|
let diagnosticText = "";
|
|
205
215
|
for (const fileEdit of applied) {
|
|
@@ -3,27 +3,44 @@
|
|
|
3
3
|
* (bash 的 `dangerouslyDisableSandbox`、编辑类工具的工作区外写入)不再弹审批框,
|
|
4
4
|
* 一律按用户「无理由拒绝」处理;`/bwrap-allow-request` 恢复审批。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* pi 给每个扩展入口单独建 jiti 实例(loader 里 `moduleCache: false`),模块级状态
|
|
7
|
+
* 不跨入口共享,所以策略实例由各入口的工厂创建,生命周期随扩展实例,聚合入口
|
|
8
|
+
* (claude-code / opencode 的 index.ts)创建一份注入给文件工具与 bash runtime。
|
|
9
|
+
*
|
|
10
|
+
* 需要跨入口一致时(独立入口的 web_fetch 也要跟随同一个开关)把 `pi.events` 传进来:
|
|
11
|
+
* 变更广播给同进程内所有订阅者,订阅由 loader 随扩展卸载自动撤销。
|
|
10
12
|
*/
|
|
11
13
|
|
|
14
|
+
import type { EventBus } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { type Static, Type } from "typebox";
|
|
16
|
+
import { Value } from "typebox/value";
|
|
17
|
+
|
|
18
|
+
/** 策略广播频道;同进程内各入口的策略副本据此保持同步。 */
|
|
19
|
+
export const REQUEST_POLICY_CHANNEL = "bwrap:request-policy";
|
|
20
|
+
|
|
21
|
+
const requestPolicyMessageSchema = Type.Object({ denyRequests: Type.Boolean() });
|
|
22
|
+
|
|
23
|
+
export type RequestPolicyMessage = Static<typeof requestPolicyMessageSchema>;
|
|
24
|
+
|
|
12
25
|
export interface RequestPolicy {
|
|
13
26
|
/** 是否拒绝一切非沙盒请求(不弹审批框,直接拒绝)。 */
|
|
14
27
|
deniesRequests(): boolean;
|
|
15
28
|
setDenyRequests(deny: boolean): void;
|
|
16
29
|
}
|
|
17
30
|
|
|
18
|
-
|
|
31
|
+
/** 不传 events 时策略只在本入口内生效(测试与不需要跨入口同步的场景)。 */
|
|
32
|
+
export function createRequestPolicy(events?: EventBus): RequestPolicy {
|
|
19
33
|
let denyRequests = false;
|
|
34
|
+
|
|
35
|
+
events?.on(REQUEST_POLICY_CHANNEL, (data) => {
|
|
36
|
+
if (Value.Check(requestPolicyMessageSchema, data)) denyRequests = data.denyRequests;
|
|
37
|
+
});
|
|
38
|
+
|
|
20
39
|
return {
|
|
21
40
|
deniesRequests: () => denyRequests,
|
|
22
41
|
setDenyRequests(deny: boolean) {
|
|
23
42
|
denyRequests = deny;
|
|
43
|
+
events?.emit(REQUEST_POLICY_CHANNEL, { denyRequests: deny } satisfies RequestPolicyMessage);
|
|
24
44
|
},
|
|
25
45
|
};
|
|
26
46
|
}
|
|
27
|
-
|
|
28
|
-
/** 进程级共享的策略实例;由 `/bwrap-deny-request` / `/bwrap-allow-request` 切换,session_start 复位。 */
|
|
29
|
-
export const requestPolicy = createRequestPolicy();
|
package/src/lib/write-guard.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
|
22
22
|
|
|
23
23
|
import { applyEdit, normalizeToLF } from "../opencode/edit-engine.js";
|
|
24
24
|
import { fenceCodeBlock } from "./markdown.js";
|
|
25
|
-
import {
|
|
25
|
+
import type { RequestPolicy } from "./request-policy.js";
|
|
26
26
|
|
|
27
27
|
const ALWAYS_ALLOW = ["/tmp"];
|
|
28
28
|
const MAX_PREVIEW_LINES = 100;
|
|
@@ -108,8 +108,16 @@ export interface WriteGuardContext {
|
|
|
108
108
|
hasUI: boolean;
|
|
109
109
|
abort?: () => void;
|
|
110
110
|
ui?: {
|
|
111
|
-
select: (
|
|
112
|
-
|
|
111
|
+
select: (
|
|
112
|
+
title: string,
|
|
113
|
+
options: string[],
|
|
114
|
+
opts?: { signal?: AbortSignal },
|
|
115
|
+
) => Promise<string | undefined>;
|
|
116
|
+
input: (
|
|
117
|
+
title: string,
|
|
118
|
+
placeholder?: string,
|
|
119
|
+
opts?: { signal?: AbortSignal },
|
|
120
|
+
) => Promise<string | undefined>;
|
|
113
121
|
};
|
|
114
122
|
}
|
|
115
123
|
|
|
@@ -121,6 +129,10 @@ export interface WriteGuardOptions {
|
|
|
121
129
|
* 待审批的变更内容;缺省时审批对话框不展示 diff 预览,仅按路径审批。
|
|
122
130
|
*/
|
|
123
131
|
change?: PendingChange;
|
|
132
|
+
/** 调用方所在扩展入口持有的非沙盒请求策略(跨入口一致时绑同一个 pi.events)。 */
|
|
133
|
+
policy: RequestPolicy;
|
|
134
|
+
/** 工具调用的中止信号:透传给审批对话框,工具被取消时对话框一起关掉。 */
|
|
135
|
+
signal?: AbortSignal;
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
/**
|
|
@@ -138,7 +150,7 @@ export async function guardWriteAccess(
|
|
|
138
150
|
|
|
139
151
|
// 非沙盒请求策略生效时不弹审批框:工作区外写入按用户点 "Block"(无理由)处理。
|
|
140
152
|
// 放在 win32 / 无 UI 分支之前,策略优先级高于各平台的降级路径。
|
|
141
|
-
if (
|
|
153
|
+
if (opts.policy.deniesRequests()) {
|
|
142
154
|
throw new Error(`user deny ${opts.toolName}: blocked`);
|
|
143
155
|
}
|
|
144
156
|
|
|
@@ -163,14 +175,18 @@ export async function guardWriteAccess(
|
|
|
163
175
|
(diffPreview ? `\n${diffPreview}\n` : "") +
|
|
164
176
|
`\nAllow?`;
|
|
165
177
|
|
|
166
|
-
const choice = await ctx.ui.select(title, ["Approve once", "Block", "Block with reason"]
|
|
178
|
+
const choice = await ctx.ui.select(title, ["Approve once", "Block", "Block with reason"], {
|
|
179
|
+
signal: opts.signal,
|
|
180
|
+
});
|
|
167
181
|
if (choice === undefined) {
|
|
168
182
|
ctx.abort?.();
|
|
169
183
|
throw new Error(`user deny ${opts.toolName}: cancelled`);
|
|
170
184
|
}
|
|
171
185
|
if (choice === "Approve once") return;
|
|
172
186
|
if (choice === "Block") throw new Error(`user deny ${opts.toolName}: blocked`);
|
|
173
|
-
const feedback = await ctx.ui.input("Why was this write denied?"
|
|
187
|
+
const feedback = await ctx.ui.input("Why was this write denied?", undefined, {
|
|
188
|
+
signal: opts.signal,
|
|
189
|
+
});
|
|
174
190
|
if (feedback === undefined) continue;
|
|
175
191
|
throw new Error(
|
|
176
192
|
feedback ? `user deny ${opts.toolName}: ${feedback}` : `user deny ${opts.toolName}: blocked`,
|
package/src/opencode/bash.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
sandboxHintBlock,
|
|
13
13
|
} from "../bwrap/runtime.js";
|
|
14
14
|
import { resolveWorkdir } from "../lib/path.js";
|
|
15
|
+
import { createRequestPolicy } from "../lib/request-policy.js";
|
|
15
16
|
|
|
16
17
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
17
18
|
const MAX_TIMEOUT_MS = 7_200_000;
|
|
@@ -41,7 +42,7 @@ function appendTruncationNotice(
|
|
|
41
42
|
*/
|
|
42
43
|
export default function opencodeBash(
|
|
43
44
|
pi: ExtensionAPI,
|
|
44
|
-
runtime: BwrapRuntime = createBwrapRuntime(),
|
|
45
|
+
runtime: BwrapRuntime = createBwrapRuntime(createRequestPolicy(pi.events)),
|
|
45
46
|
): void {
|
|
46
47
|
// 每个扩展实例持有自己的 runtime:不依赖模块级全局状态,状态随扩展
|
|
47
48
|
// 实例生命周期(进程启动 / /reload / session 切换时工厂重建即重置)。
|
package/src/opencode/files.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
|
|
|
34
34
|
import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
|
|
35
35
|
import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
|
|
36
36
|
import { formatSubtitlePath } from "../lib/path.js";
|
|
37
|
+
import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
|
|
37
38
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
38
39
|
import { applyEdit, normalizeToLF, stripBom } from "./edit-engine.js";
|
|
39
40
|
|
|
@@ -200,7 +201,7 @@ export interface LinePage {
|
|
|
200
201
|
*/
|
|
201
202
|
export async function readLines(
|
|
202
203
|
filePath: string,
|
|
203
|
-
opts: { offset: number; limit: number; maxBytes?: number },
|
|
204
|
+
opts: { offset: number; limit: number; maxBytes?: number; signal?: AbortSignal },
|
|
204
205
|
): Promise<LinePage> {
|
|
205
206
|
if (opts.offset < 0) return readTailLines(filePath, opts);
|
|
206
207
|
const start = opts.offset - 1;
|
|
@@ -215,6 +216,8 @@ export async function readLines(
|
|
|
215
216
|
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
216
217
|
try {
|
|
217
218
|
for await (const text of rl) {
|
|
219
|
+
// 大文件的全量扫描可能持续数秒:每行检查一次取消,别等到读完才响应
|
|
220
|
+
opts.signal?.throwIfAborted();
|
|
218
221
|
count += 1;
|
|
219
222
|
if (count <= start) continue;
|
|
220
223
|
|
|
@@ -249,7 +252,7 @@ export async function readLines(
|
|
|
249
252
|
*/
|
|
250
253
|
async function readTailLines(
|
|
251
254
|
filePath: string,
|
|
252
|
-
opts: { offset: number; limit: number; maxBytes?: number },
|
|
255
|
+
opts: { offset: number; limit: number; maxBytes?: number; signal?: AbortSignal },
|
|
253
256
|
): Promise<LinePage> {
|
|
254
257
|
const windowSize = -opts.offset;
|
|
255
258
|
const window: string[] = [];
|
|
@@ -260,6 +263,7 @@ async function readTailLines(
|
|
|
260
263
|
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
261
264
|
try {
|
|
262
265
|
for await (const text of rl) {
|
|
266
|
+
opts.signal?.throwIfAborted();
|
|
263
267
|
count += 1;
|
|
264
268
|
const line =
|
|
265
269
|
text.length > MAX_LINE_LENGTH ? text.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text;
|
|
@@ -278,6 +282,7 @@ async function readTailLines(
|
|
|
278
282
|
let cut = false;
|
|
279
283
|
let more = false;
|
|
280
284
|
for (const line of window.slice(head)) {
|
|
285
|
+
opts.signal?.throwIfAborted();
|
|
281
286
|
if (raw.length >= opts.limit) {
|
|
282
287
|
more = true;
|
|
283
288
|
break;
|
|
@@ -466,6 +471,7 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
466
471
|
const page = await readLines(absolutePath, {
|
|
467
472
|
offset: effectiveOffset,
|
|
468
473
|
limit: limit ?? DEFAULT_MAX_LINES,
|
|
474
|
+
signal,
|
|
469
475
|
});
|
|
470
476
|
|
|
471
477
|
if (page.count < page.offset && !(page.count === 0 && page.offset === 1)) {
|
|
@@ -534,7 +540,11 @@ const editSchema = Type.Object({
|
|
|
534
540
|
),
|
|
535
541
|
});
|
|
536
542
|
|
|
537
|
-
function registerEditTool(
|
|
543
|
+
function registerEditTool(
|
|
544
|
+
pi: ExtensionAPI,
|
|
545
|
+
getService: () => LspService,
|
|
546
|
+
policy: RequestPolicy,
|
|
547
|
+
): void {
|
|
538
548
|
pi.registerTool({
|
|
539
549
|
name: "edit",
|
|
540
550
|
label: "edit",
|
|
@@ -565,6 +575,8 @@ function registerEditTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
565
575
|
toolName: "edit",
|
|
566
576
|
absolutePath,
|
|
567
577
|
change: { oldText: oldString, newText: newString, replaceAll },
|
|
578
|
+
policy,
|
|
579
|
+
signal,
|
|
568
580
|
});
|
|
569
581
|
|
|
570
582
|
const [message, details, diagnostics] = await withFileMutationQueue(
|
|
@@ -686,7 +698,11 @@ export function resolveBom(
|
|
|
686
698
|
return { bom: sourceBom || nextBom, text };
|
|
687
699
|
}
|
|
688
700
|
|
|
689
|
-
function registerWriteTool(
|
|
701
|
+
function registerWriteTool(
|
|
702
|
+
pi: ExtensionAPI,
|
|
703
|
+
getService: () => LspService,
|
|
704
|
+
policy: RequestPolicy,
|
|
705
|
+
): void {
|
|
690
706
|
pi.registerTool({
|
|
691
707
|
name: "write",
|
|
692
708
|
label: "write",
|
|
@@ -707,6 +723,8 @@ function registerWriteTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
707
723
|
toolName: "write",
|
|
708
724
|
absolutePath,
|
|
709
725
|
change: { oldText: "", newText: content },
|
|
726
|
+
policy,
|
|
727
|
+
signal,
|
|
710
728
|
});
|
|
711
729
|
const dir = dirname(absolutePath);
|
|
712
730
|
|
|
@@ -764,25 +782,40 @@ function registerWriteTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
764
782
|
|
|
765
783
|
// ── 入口 ─────────────────────────────────────────────────────────────────────
|
|
766
784
|
|
|
767
|
-
export function registerFileTools(
|
|
785
|
+
export function registerFileTools(
|
|
786
|
+
pi: ExtensionAPI,
|
|
787
|
+
getService: () => LspService,
|
|
788
|
+
policy: RequestPolicy,
|
|
789
|
+
): void {
|
|
768
790
|
registerReadTool(pi, getService);
|
|
769
|
-
registerEditTool(pi, getService);
|
|
770
|
-
registerWriteTool(pi, getService);
|
|
791
|
+
registerEditTool(pi, getService, policy);
|
|
792
|
+
registerWriteTool(pi, getService, policy);
|
|
771
793
|
// lsp-rename / inspect 工具由 manager 的 onEnabled 回调注册,不在这里注册。
|
|
772
794
|
}
|
|
773
795
|
|
|
796
|
+
export interface OpencodeFileToolOptions extends LspServiceOptions {
|
|
797
|
+
/** 与 bash runtime 共享的非沙盒请求策略;独立入口不传,自建一份。 */
|
|
798
|
+
policy?: RequestPolicy;
|
|
799
|
+
}
|
|
800
|
+
|
|
774
801
|
/** 独立入口:创建 LSP manager(session_start 时按配置启用)并注册文件工具。 */
|
|
775
|
-
export default function opencodeFileTools(
|
|
802
|
+
export default function opencodeFileTools(
|
|
803
|
+
pi: ExtensionAPI,
|
|
804
|
+
options?: OpencodeFileToolOptions,
|
|
805
|
+
): void {
|
|
806
|
+
// 聚合入口(index.ts)注入与 bash runtime 共享的那一份;独立入口自建并靠
|
|
807
|
+
// pi.events 跟随同一开关。
|
|
808
|
+
const policy = options?.policy ?? createRequestPolicy(pi.events);
|
|
776
809
|
const manager = createLspManager(
|
|
777
810
|
pi,
|
|
778
811
|
{
|
|
779
812
|
onEnabled: (pi, service) => {
|
|
780
|
-
registerLspRenameTool(pi, service);
|
|
813
|
+
registerLspRenameTool(pi, service, { policy });
|
|
781
814
|
registerLspInspectTools(pi, service);
|
|
782
815
|
},
|
|
783
816
|
},
|
|
784
817
|
options,
|
|
785
818
|
);
|
|
786
819
|
// 文件工具无条件注册;service 惰性获取,disabled 时为 no-op。
|
|
787
|
-
registerFileTools(pi, () => manager.mustLazyGetService());
|
|
820
|
+
registerFileTools(pi, () => manager.mustLazyGetService(), policy);
|
|
788
821
|
}
|
package/src/opencode/index.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
|
|
17
|
+
import { createBwrapRuntime } from "../bwrap/runtime.js";
|
|
18
|
+
import { createRequestPolicy } from "../lib/request-policy.js";
|
|
17
19
|
import opencodeBash from "./bash.js";
|
|
18
20
|
import opencodeFileTools from "./files.js";
|
|
19
21
|
import opencodeQuestion from "./question.js";
|
|
@@ -40,8 +42,11 @@ export { default as opencodeQuestion } from "./question.js";
|
|
|
40
42
|
export { default as opencodeTodo } from "./todo.js";
|
|
41
43
|
|
|
42
44
|
export default function opencode(pi: ExtensionAPI) {
|
|
43
|
-
|
|
45
|
+
// 非沙盒请求策略由本入口创建,注入文件工具与 bash runtime 共享同一份;
|
|
46
|
+
// 独立入口(web/fetch.ts 等)各自创建一份并经 pi.events 保持同步。
|
|
47
|
+
const policy = createRequestPolicy(pi.events);
|
|
48
|
+
opencodeFileTools(pi, { policy });
|
|
44
49
|
opencodeTodo(pi);
|
|
45
50
|
opencodeQuestion(pi);
|
|
46
|
-
opencodeBash(pi);
|
|
51
|
+
opencodeBash(pi, createBwrapRuntime(policy));
|
|
47
52
|
}
|
package/src/opencode/question.ts
CHANGED
|
@@ -110,7 +110,11 @@ function dialogTitle(q: Question): string {
|
|
|
110
110
|
return q.header ? `${q.header}: ${q.question}` : q.question;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
async function askSingle(
|
|
113
|
+
async function askSingle(
|
|
114
|
+
q: Question,
|
|
115
|
+
ctx: ExtensionContext,
|
|
116
|
+
signal: AbortSignal | undefined,
|
|
117
|
+
): Promise<Answer> {
|
|
114
118
|
const title = dialogTitle(q);
|
|
115
119
|
const result = await selectWithOptionalInput(
|
|
116
120
|
title,
|
|
@@ -119,17 +123,22 @@ async function askSingle(q: Question, ctx: ExtensionContext): Promise<Answer> {
|
|
|
119
123
|
{ label: CUSTOM_LABEL, inputPrompt: "Type your answer…" },
|
|
120
124
|
],
|
|
121
125
|
ctx.ui,
|
|
126
|
+
{ signal },
|
|
122
127
|
);
|
|
123
128
|
if (result === undefined) return [];
|
|
124
129
|
return result.prompted ? (result.input ? [result.input] : []) : [result.label];
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
async function askMultiple(
|
|
132
|
+
async function askMultiple(
|
|
133
|
+
q: Question,
|
|
134
|
+
ctx: ExtensionContext,
|
|
135
|
+
signal: AbortSignal | undefined,
|
|
136
|
+
): Promise<Answer> {
|
|
128
137
|
const selected = await selectMultiple(
|
|
129
138
|
dialogTitle(q),
|
|
130
139
|
q.options.map((o) => ({ label: o.label })),
|
|
131
140
|
ctx.ui,
|
|
132
|
-
{ doneLabel: DONE_LABEL },
|
|
141
|
+
{ signal, doneLabel: DONE_LABEL },
|
|
133
142
|
);
|
|
134
143
|
return selected;
|
|
135
144
|
}
|
|
@@ -145,7 +154,7 @@ export default function question(pi: ExtensionAPI) {
|
|
|
145
154
|
// 交互式提问不能与其他工具并行弹出,逐个串行执行
|
|
146
155
|
executionMode: "sequential",
|
|
147
156
|
|
|
148
|
-
async execute(_toolCallId, params,
|
|
157
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
149
158
|
if (!ctx.hasUI) {
|
|
150
159
|
throw new Error("Cannot ask questions: interactive UI is not available in this mode");
|
|
151
160
|
}
|
|
@@ -153,7 +162,11 @@ export default function question(pi: ExtensionAPI) {
|
|
|
153
162
|
|
|
154
163
|
const answers: Answer[] = [];
|
|
155
164
|
for (const q of questions) {
|
|
156
|
-
|
|
165
|
+
// 每个问题前检查一次:上一轮对话框期间被取消时不再弹下一个
|
|
166
|
+
signal?.throwIfAborted();
|
|
167
|
+
answers.push(
|
|
168
|
+
q.multiple ? await askMultiple(q, ctx, signal) : await askSingle(q, ctx, signal),
|
|
169
|
+
);
|
|
157
170
|
}
|
|
158
171
|
|
|
159
172
|
return {
|
package/src/spawn-agent.ts
CHANGED
|
@@ -532,7 +532,8 @@ export default function spawnAgent(pi: ExtensionAPI) {
|
|
|
532
532
|
name: "spawn-agent",
|
|
533
533
|
label: "spawn-agent",
|
|
534
534
|
description: [
|
|
535
|
-
"Delegate a task to a subagent
|
|
535
|
+
"Delegate a task to a subagent that runs in an isolated session with its own context window, inside this pi process rather than a separate one.",
|
|
536
|
+
"The call blocks until the subagent finishes its turn; its final output comes back as the tool result.",
|
|
536
537
|
"The `agent` parameter must be one of the available subagent types listed in the system prompt.",
|
|
537
538
|
`Subagents run read-only (${DEFAULT_TOOLS.join(", ")}) unless the agent declares an explicit toolset.`,
|
|
538
539
|
].join(" "),
|
package/src/talk/core.ts
CHANGED
|
@@ -148,6 +148,8 @@ export class TalkCore {
|
|
|
148
148
|
private sweeper: ReturnType<typeof setInterval> | undefined;
|
|
149
149
|
private initialDrain: ReturnType<typeof setTimeout> | undefined;
|
|
150
150
|
private lastDeliveryFailureAt = 0;
|
|
151
|
+
/** 已上报故障的后台动作名;成功一次后移除,允许再次上报(见 runInBackground)。 */
|
|
152
|
+
private readonly backgroundFailuresReported = new Set<string>();
|
|
151
153
|
|
|
152
154
|
constructor(options: TalkCoreOptions) {
|
|
153
155
|
this.storage = options.storage;
|
|
@@ -168,11 +170,18 @@ export class TalkCore {
|
|
|
168
170
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
169
171
|
|
|
170
172
|
async start(self: AgentRecord): Promise<void> {
|
|
171
|
-
await this.storage.init();
|
|
172
173
|
// Record the process start time so presence can rule out pid reuse later.
|
|
173
174
|
const pidStart = readStartTime(self.pid);
|
|
174
175
|
this.self = pidStart === undefined ? self : { ...self, pidStart };
|
|
175
|
-
|
|
176
|
+
try {
|
|
177
|
+
await this.storage.init();
|
|
178
|
+
await writeRecord(this.storage, this.self);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// 注册失败不是致命错误:本进程的 talk 工具照常可用,只是对等方看不到本
|
|
181
|
+
// agent。调用方(pi 的 session_start 回调)是 fire-and-forget,抛出去就是
|
|
182
|
+
// unhandled rejection,pi 会整体退出。
|
|
183
|
+
this.notifyBackgroundFailure("registration", error);
|
|
184
|
+
}
|
|
176
185
|
try {
|
|
177
186
|
await sweep(this.storage, this.now());
|
|
178
187
|
} catch {
|
|
@@ -181,13 +190,13 @@ export class TalkCore {
|
|
|
181
190
|
this.startInboxPoll();
|
|
182
191
|
// Reclaim dead records periodically, not just at startup.
|
|
183
192
|
this.sweeper = setInterval(() => {
|
|
184
|
-
|
|
193
|
+
this.runInBackground("record sweep", () => sweep(this.storage, this.now()));
|
|
185
194
|
}, SWEEP_INTERVAL_MS);
|
|
186
195
|
this.sweeper.unref();
|
|
187
196
|
// Drain mail queued while offline — deferred: delivering during
|
|
188
197
|
// session_start races the agent's own first turn.
|
|
189
198
|
const initial = setTimeout(() => {
|
|
190
|
-
|
|
199
|
+
this.runInBackground("inbox poll", () => this.checkInbox());
|
|
191
200
|
}, INITIAL_DRAIN_DELAY_MS);
|
|
192
201
|
initial.unref();
|
|
193
202
|
this.initialDrain = initial;
|
|
@@ -235,10 +244,38 @@ export class TalkCore {
|
|
|
235
244
|
}
|
|
236
245
|
}
|
|
237
246
|
|
|
247
|
+
/**
|
|
248
|
+
* 后台动作失败的上报:同一个动作只报一次(轮询间隔只有几秒,持续失败会刷屏),
|
|
249
|
+
* 该动作下次成功后再失败会重新上报。通知通道本身出错也不能逃逸。
|
|
250
|
+
*/
|
|
251
|
+
private notifyBackgroundFailure(action: string, error: unknown): void {
|
|
252
|
+
if (this.backgroundFailuresReported.has(action)) return;
|
|
253
|
+
this.backgroundFailuresReported.add(action);
|
|
254
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
255
|
+
try {
|
|
256
|
+
this.events.notify(`talk: ${action} failed: ${detail}`);
|
|
257
|
+
} catch {
|
|
258
|
+
// 通知通道不可用(如陈旧会话):无处上报,也绝不能让它变成 rejection
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 后台动作的 fire-and-forget 包装:收件箱轮询、记录回收与 presence 监视都没有
|
|
264
|
+
* 调用方可以 await,rejection 逃逸出去就是 unhandled rejection —— pi 会因此整体
|
|
265
|
+
* 退出(LSP 扩展踩过同一个坑)。这类失败只上报,agent 继续运行。
|
|
266
|
+
*/
|
|
267
|
+
private runInBackground(action: string, task: () => Promise<void>): void {
|
|
268
|
+
void task()
|
|
269
|
+
.then(() => this.backgroundFailuresReported.delete(action))
|
|
270
|
+
.catch((error: unknown) => {
|
|
271
|
+
this.notifyBackgroundFailure(action, error);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
238
275
|
private startInboxPoll(): void {
|
|
239
276
|
if (this.inboxPoll) return;
|
|
240
277
|
this.inboxPoll = setInterval(() => {
|
|
241
|
-
|
|
278
|
+
this.runInBackground("inbox poll", () => this.checkInbox());
|
|
242
279
|
}, INBOX_POLL_MS);
|
|
243
280
|
this.inboxPoll.unref();
|
|
244
281
|
}
|
|
@@ -752,7 +789,7 @@ export class TalkCore {
|
|
|
752
789
|
private startWatchPoller(): void {
|
|
753
790
|
if (this.watchPoller) return;
|
|
754
791
|
this.watchPoller = setInterval(() => {
|
|
755
|
-
|
|
792
|
+
this.runInBackground("presence watch", () => this.pollWatched());
|
|
756
793
|
}, WATCH_POLL_MS);
|
|
757
794
|
this.watchPoller.unref();
|
|
758
795
|
}
|
package/src/talk/index.ts
CHANGED
|
@@ -187,7 +187,16 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
187
187
|
lastSeenAt: now,
|
|
188
188
|
status: "idle",
|
|
189
189
|
};
|
|
190
|
-
|
|
190
|
+
// core.start 内部的注册失败会自己上报(见 core.ts 的 notifyBackgroundFailure);
|
|
191
|
+
// 这里再兜一层:这是 fire-and-forget 调用,没有任何调用方 await,rejection
|
|
192
|
+
// 逃逸出去就是 unhandled rejection,pi 会整体退出。
|
|
193
|
+
void core
|
|
194
|
+
.start(self)
|
|
195
|
+
.then(() => refreshGroupStatus(ctx.ui))
|
|
196
|
+
.catch(() => {
|
|
197
|
+
// 注册失败由 core 上报,refreshGroupStatus 内部也有 try/catch;
|
|
198
|
+
// 这里只保证不再抛(含会话已切走时 ctx 变 stale 的情况)
|
|
199
|
+
});
|
|
191
200
|
});
|
|
192
201
|
|
|
193
202
|
pi.on("agent_start", () => core.setWorking());
|
package/src/web/fetch.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { Type } from "typebox";
|
|
|
23
23
|
|
|
24
24
|
import { resolvePathArg } from "../lib/path.js";
|
|
25
25
|
import { createHttpProxy } from "../lib/proxy.js";
|
|
26
|
+
import { createRequestPolicy } from "../lib/request-policy.js";
|
|
26
27
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
27
28
|
|
|
28
29
|
const httpProxy = createHttpProxy();
|
|
@@ -310,6 +311,9 @@ function truncateMarkdown(text: string): { text: string; truncated: boolean } {
|
|
|
310
311
|
}
|
|
311
312
|
|
|
312
313
|
export default function webFetchTool(pi: ExtensionAPI): void {
|
|
314
|
+
// 本工具是独立扩展入口(pi 给每个入口单独建 jiti 实例),自建一份非沙盒请求
|
|
315
|
+
// 策略并订阅 pi.events:/bwrap-deny-request 在 bash 入口切换时会同步过来。
|
|
316
|
+
const policy = createRequestPolicy(pi.events);
|
|
313
317
|
pi.registerTool({
|
|
314
318
|
name: "web_fetch",
|
|
315
319
|
label: "Web Fetch",
|
|
@@ -339,7 +343,12 @@ export default function webFetchTool(pi: ExtensionAPI): void {
|
|
|
339
343
|
// 落盘位置的审批与写文件工具同一套:工作区与 /tmp 自动放行,其余问用户。
|
|
340
344
|
// 放在 try 外面,拒绝的原因(user deny)不该被改写成「抓取失败」。
|
|
341
345
|
if (destination !== undefined) {
|
|
342
|
-
await guardWriteAccess(ctx, {
|
|
346
|
+
await guardWriteAccess(ctx, {
|
|
347
|
+
toolName: "web_fetch",
|
|
348
|
+
absolutePath: destination,
|
|
349
|
+
policy,
|
|
350
|
+
signal,
|
|
351
|
+
});
|
|
343
352
|
}
|
|
344
353
|
|
|
345
354
|
try {
|