@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.562",
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": "tsx bin/sandbox.ts",
28
- "aft-search": "tsx bin/aft-search.ts",
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",
@@ -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 { requestPolicy } from "../lib/request-policy.js";
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
- requestPolicy.setDenyRequests(false);
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 && requestPolicy.deniesRequests()
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 && requestPolicy.deniesRequests()) {
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}${requestPolicy.deniesRequests() ? " (requests denied)" : ""}`;
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
- requestPolicy.setDenyRequests(deny);
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
- export function createBwrapRuntime(): BwrapRuntime {
920
- return new BwrapRuntime();
928
+ /** 缺省自建一份不跨入口同步的策略;入口通常显式传入 `createRequestPolicy(pi.events)`。 */
929
+ export function createBwrapRuntime(policy: RequestPolicy = createRequestPolicy()): BwrapRuntime {
930
+ return new BwrapRuntime(policy);
921
931
  }
@@ -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
- * 长度时钳到文件开头(与 tail 一致,不告警)。输出行号始终为绝对行号;
124
- * offset=0 的「行号从 0 起」是对齐 Claude Code lineOffset 的历史语义。
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
- const startIndex = offset === 0 ? 0 : offset > 0 ? offset - 1 : Math.max(totalLines + offset, 0);
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 selected =
148
- limit === undefined ? lines.slice(startIndex) : lines.slice(startIndex, startIndex + limit);
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(pi: ExtensionAPI, options?: LspServiceOptions): void {
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
  }
@@ -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
- * 本文件不再持有 state;spawn-agent 子代理按工具名直接 `-e` 加载各模块文件
13
- * (files.ts / grep.ts / glob.ts 均为独立扩展入口)。
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
- claudeCodeFileTools(pi);
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
  }
@@ -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({
@@ -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
- /** 超过容量时淘汰最久未使用的文档(didClose 并移出驻留集合)。 */
719
+ /**
720
+ * 超过容量时淘汰最久未使用的文档(didClose 并移出驻留集合)。
721
+ *
722
+ * 单次有界遍历:容量是尽力而为的上限——等待诊断的文档要留到诊断收集完,可淘汰
723
+ * 项不足时就少淘汰(见 evictionPlan)。不要写成"跳过等待项再重试"的循环:等待
724
+ * 窗口内的文档可能占满驻留集合(例如某路径的等待尚未结束时它又被重新打开),
725
+ * 那时重试分支没有 await,等于同步自旋。
726
+ */
659
727
  async function evictExcess(): Promise<void> {
660
- while (lruOrder.size > maxOpenDocuments) {
661
- const oldest = lruOrder.keys().next().value;
662
- if (oldest === undefined) return;
663
- lruOrder.delete(oldest);
664
- const document = files[oldest];
665
- if (document === undefined) continue;
666
- if (waitingForDiagnostics.has(oldest)) {
667
- // 防御:等待中的文档挪到 MRU,等下轮再淘汰(正常不会发生,刚 touch 即 MRU)
668
- touch(oldest);
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(oldest).href },
738
+ textDocument: { uri: pathToFileURL(path).href },
673
739
  });
674
- delete files[oldest];
675
- documentVersions.delete(oldest);
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
- const sendInspectRequest = async <T>(method: string, message: object): Promise<T> => {
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 retryOnContentModified(() => connection.sendRequest<T>(method, message));
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
- connection.sendRequest<{ uri: string }[] | null>("textDocument/references", {
1174
- textDocument: { uri },
1175
- position,
1176
- context: { includeDeclaration: true },
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
- connection.sendRequest<WorkspaceEdit | null>("textDocument/rename", {
1191
- textDocument: { uri },
1192
- position,
1193
- newName: request.newName,
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
- connection.sendRequest<PrepareRenameResponse | null>("textDocument/prepareRename", {
1210
- textDocument: { uri },
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 sleep(renameVerificationTiming.pollMs);
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>("textDocument/definition", {
1270
- textDocument: { uri },
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>("textDocument/references", {
1278
- textDocument: { uri },
1279
- position,
1280
- context: { includeDeclaration: true },
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>("textDocument/hover", {
1287
- textDocument: { uri },
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[]>();
@@ -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 = { path: request.file, line: request.line, character: request.character };
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 LspRenameHooks {
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
- hooks: LspRenameHooks = {},
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 options = { notify, signal };
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
- const successes: { result: Awaited<ReturnType<typeof service.rename>> }[] = [];
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: typeof candidates }
159
+ { result: (typeof successes)[number]["result"]; candidates: LspPosition[] }
151
160
  >();
152
- for (const [index, candidate] of candidates.entries()) {
153
- const entry = successes.at(index);
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: entry.result, candidates: [candidate] });
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 = hooks.recordReads === undefined ? undefined : await hooks.recordReads(applied);
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
- * 状态由闭包持有(AGENTS.md:不用模块级可变变量维护跨调用状态)。
7
- * 模块级单例让互相独立的扩展入口(bwrap runtime、claude-code / opencode 的文件工具、
8
- * lsp-rename、web_fetch)共享同一份会话状态——各入口分别 createRequestPolicy()
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
- export function createRequestPolicy(): RequestPolicy {
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();
@@ -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 { requestPolicy } from "./request-policy.js";
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: (title: string, options: string[]) => Promise<string | undefined>;
112
- input: (title: string, placeholder?: string) => Promise<string | undefined>;
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 (requestPolicy.deniesRequests()) {
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`,
@@ -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 切换时工厂重建即重置)。
@@ -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(pi: ExtensionAPI, getService: () => LspService): void {
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(pi: ExtensionAPI, getService: () => LspService): void {
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(pi: ExtensionAPI, getService: () => LspService): void {
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(pi: ExtensionAPI, options?: LspServiceOptions): void {
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
  }
@@ -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
- opencodeFileTools(pi);
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
  }
@@ -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(q: Question, ctx: ExtensionContext): Promise<Answer> {
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(q: Question, ctx: ExtensionContext): Promise<Answer> {
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, _signal, _onUpdate, ctx) {
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
- answers.push(q.multiple ? await askMultiple(q, ctx) : await askSingle(q, ctx));
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 {
@@ -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 running in a separate pi process with an isolated context window.",
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
- await writeRecord(this.storage, this.self);
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
- void sweep(this.storage, this.now());
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
- void this.checkInbox();
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
- void this.checkInbox();
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
- void this.pollWatched();
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
- void core.start(self).then(() => refreshGroupStatus(ctx.ui));
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, { toolName: "web_fetch", absolutePath: destination });
346
+ await guardWriteAccess(ctx, {
347
+ toolName: "web_fetch",
348
+ absolutePath: destination,
349
+ policy,
350
+ signal,
351
+ });
343
352
  }
344
353
 
345
354
  try {