@trim21/personal-pi-extensions 0.1.564 → 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.564",
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": [
@@ -120,9 +120,10 @@ function splitFileLines(content: string): string[] {
120
120
  * 易误判多一层缩进。limit 未指定时读取全部。无 PARTIAL 提示、无单行截断
121
121
  * (由 execute 层的字节/token 上限兜底)。
122
122
  *
123
- * offset 为负数时从文件末尾倒数(`offset=-5` 等价 `tail -n 5`),超出文件
124
- * 长度时钳到文件开头(与 tail 一致,不告警)。输出行号始终为绝对行号;
125
- * offset=0 的「行号从 0 起」是对齐 Claude Code lineOffset 的历史语义。
123
+ * offset 为负数时从文件末尾倒数(`offset=-5` 等价 `tail -n 5`)——倒数按**真实
124
+ * 行**计算,splitFileLines 补出的尾随幻影行不算一行也不在输出里;超出文件长度时
125
+ * 钳到文件开头(与 tail 一致,不告警)。输出行号始终为绝对行号;offset=0 的
126
+ * 「行号从 0 起」是对齐 Claude Code lineOffset 的历史语义。
126
127
  */
127
128
  export function formatReadOutput(
128
129
  content: string,
@@ -143,10 +144,18 @@ export function formatReadOutput(
143
144
  totalLines,
144
145
  };
145
146
  }
146
- 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;
147
152
  const firstLine = offset === 0 ? 0 : startIndex + 1;
148
- const selected =
149
- 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);
150
159
  const text = selected.map((line, index) => `${firstLine + index}: ${line}`).join("\n");
151
160
  return { text, totalLines };
152
161
  }
@@ -342,6 +351,7 @@ export function registerFileTools(
342
351
  // 与 Edit / Write 同一条驻留路径:didOpen 后等待该文件的诊断并报告
343
352
  const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
344
353
  notify: (message, level) => ctx.ui.notify(message, level),
354
+ signal,
345
355
  });
346
356
  return {
347
357
  content: [
@@ -402,6 +412,7 @@ export function registerFileTools(
402
412
  replaceAll: params.replace_all,
403
413
  },
404
414
  policy,
415
+ signal,
405
416
  });
406
417
  const [message, details, diagnostics] = await withFileMutationQueue<
407
418
  [string, FileToolDetails, DiagnosticReport]
@@ -535,6 +546,7 @@ export function registerFileTools(
535
546
  signal?.throwIfAborted();
536
547
  const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
537
548
  notify: (message, level) => ctx.ui.notify(message, level),
549
+ signal,
538
550
  });
539
551
  return [
540
552
  text,
@@ -598,6 +610,7 @@ export function registerFileTools(
598
610
  absolutePath: filePath,
599
611
  change: { oldText: "", newText: params.content },
600
612
  policy,
613
+ signal,
601
614
  });
602
615
  const [message, details, diagnostics] = await withFileMutationQueue<
603
616
  [string, FileToolDetails, DiagnosticReport]
@@ -632,6 +645,7 @@ export function registerFileTools(
632
645
  signal?.throwIfAborted();
633
646
  const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
634
647
  notify: (message, level) => ctx.ui.notify(message, level),
648
+ signal,
635
649
  });
636
650
  return [
637
651
  text,
@@ -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
  }
@@ -31,6 +31,7 @@ import {
31
31
  type AppliedFileEdit,
32
32
  canonicalizeEdit,
33
33
  expandWorkspaceEdit,
34
+ type LspPosition,
34
35
  symbolCandidates,
35
36
  } from "./rename.js";
36
37
 
@@ -117,7 +118,12 @@ export function registerLspRenameTool(
117
118
  `Symbol "${params.symbol}" not found on line ${params.line} of ${filePath}. Read the file again and locate the symbol.`,
118
119
  );
119
120
  }
120
- 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
+ }[] = [];
121
127
  const notPossibleErrors: string[] = [];
122
128
  for (const candidate of candidates) {
123
129
  signal?.throwIfAborted();
@@ -130,7 +136,7 @@ export function registerLspRenameTool(
130
136
  newName: params.new_name,
131
137
  options: requestOptions,
132
138
  });
133
- successes.push({ result });
139
+ successes.push({ candidate, result });
134
140
  } catch (error) {
135
141
  if (error instanceof RenameNotPossibleError) {
136
142
  notPossibleErrors.push(error.message);
@@ -150,15 +156,13 @@ export function registerLspRenameTool(
150
156
  // 同一符号的多次出现编辑集合一致;不一致即为不同符号 → 要求补 character
151
157
  const groups = new Map<
152
158
  string,
153
- { result: (typeof successes)[number]["result"]; candidates: typeof candidates }
159
+ { result: (typeof successes)[number]["result"]; candidates: LspPosition[] }
154
160
  >();
155
- for (const [index, candidate] of candidates.entries()) {
156
- const entry = successes.at(index);
157
- if (!entry) continue;
158
- const key = canonicalizeEdit(entry.result.edit);
161
+ for (const { candidate, result: probe } of successes) {
162
+ const key = canonicalizeEdit(probe.edit);
159
163
  const group = groups.get(key);
160
164
  if (group) group.candidates.push(candidate);
161
- else groups.set(key, { result: entry.result, candidates: [candidate] });
165
+ else groups.set(key, { result: probe, candidates: [candidate] });
162
166
  }
163
167
  if (groups.size > 1) {
164
168
  const listing = [...groups.values()]
@@ -191,6 +195,7 @@ export function registerLspRenameTool(
191
195
  absolutePath: fileEdit.path,
192
196
  change: { oldText: fileEdit.oldText, newText: fileEdit.newText },
193
197
  policy: options.policy,
198
+ signal,
194
199
  });
195
200
  }
196
201
 
@@ -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
 
@@ -123,6 +131,8 @@ export interface WriteGuardOptions {
123
131
  change?: PendingChange;
124
132
  /** 调用方所在扩展入口持有的非沙盒请求策略(跨入口一致时绑同一个 pi.events)。 */
125
133
  policy: RequestPolicy;
134
+ /** 工具调用的中止信号:透传给审批对话框,工具被取消时对话框一起关掉。 */
135
+ signal?: AbortSignal;
126
136
  }
127
137
 
128
138
  /**
@@ -165,14 +175,18 @@ export async function guardWriteAccess(
165
175
  (diffPreview ? `\n${diffPreview}\n` : "") +
166
176
  `\nAllow?`;
167
177
 
168
- 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
+ });
169
181
  if (choice === undefined) {
170
182
  ctx.abort?.();
171
183
  throw new Error(`user deny ${opts.toolName}: cancelled`);
172
184
  }
173
185
  if (choice === "Approve once") return;
174
186
  if (choice === "Block") throw new Error(`user deny ${opts.toolName}: blocked`);
175
- 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
+ });
176
190
  if (feedback === undefined) continue;
177
191
  throw new Error(
178
192
  feedback ? `user deny ${opts.toolName}: ${feedback}` : `user deny ${opts.toolName}: blocked`,
@@ -201,7 +201,7 @@ export interface LinePage {
201
201
  */
202
202
  export async function readLines(
203
203
  filePath: string,
204
- opts: { offset: number; limit: number; maxBytes?: number },
204
+ opts: { offset: number; limit: number; maxBytes?: number; signal?: AbortSignal },
205
205
  ): Promise<LinePage> {
206
206
  if (opts.offset < 0) return readTailLines(filePath, opts);
207
207
  const start = opts.offset - 1;
@@ -216,6 +216,8 @@ export async function readLines(
216
216
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
217
217
  try {
218
218
  for await (const text of rl) {
219
+ // 大文件的全量扫描可能持续数秒:每行检查一次取消,别等到读完才响应
220
+ opts.signal?.throwIfAborted();
219
221
  count += 1;
220
222
  if (count <= start) continue;
221
223
 
@@ -250,7 +252,7 @@ export async function readLines(
250
252
  */
251
253
  async function readTailLines(
252
254
  filePath: string,
253
- opts: { offset: number; limit: number; maxBytes?: number },
255
+ opts: { offset: number; limit: number; maxBytes?: number; signal?: AbortSignal },
254
256
  ): Promise<LinePage> {
255
257
  const windowSize = -opts.offset;
256
258
  const window: string[] = [];
@@ -261,6 +263,7 @@ async function readTailLines(
261
263
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
262
264
  try {
263
265
  for await (const text of rl) {
266
+ opts.signal?.throwIfAborted();
264
267
  count += 1;
265
268
  const line =
266
269
  text.length > MAX_LINE_LENGTH ? text.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : text;
@@ -279,6 +282,7 @@ async function readTailLines(
279
282
  let cut = false;
280
283
  let more = false;
281
284
  for (const line of window.slice(head)) {
285
+ opts.signal?.throwIfAborted();
282
286
  if (raw.length >= opts.limit) {
283
287
  more = true;
284
288
  break;
@@ -467,6 +471,7 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
467
471
  const page = await readLines(absolutePath, {
468
472
  offset: effectiveOffset,
469
473
  limit: limit ?? DEFAULT_MAX_LINES,
474
+ signal,
470
475
  });
471
476
 
472
477
  if (page.count < page.offset && !(page.count === 0 && page.offset === 1)) {
@@ -571,6 +576,7 @@ function registerEditTool(
571
576
  absolutePath,
572
577
  change: { oldText: oldString, newText: newString, replaceAll },
573
578
  policy,
579
+ signal,
574
580
  });
575
581
 
576
582
  const [message, details, diagnostics] = await withFileMutationQueue(
@@ -718,6 +724,7 @@ function registerWriteTool(
718
724
  absolutePath,
719
725
  change: { oldText: "", newText: content },
720
726
  policy,
727
+ signal,
721
728
  });
722
729
  const dir = dirname(absolutePath);
723
730
 
@@ -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
@@ -343,7 +343,12 @@ export default function webFetchTool(pi: ExtensionAPI): void {
343
343
  // 落盘位置的审批与写文件工具同一套:工作区与 /tmp 自动放行,其余问用户。
344
344
  // 放在 try 外面,拒绝的原因(user deny)不该被改写成「抓取失败」。
345
345
  if (destination !== undefined) {
346
- await guardWriteAccess(ctx, { toolName: "web_fetch", absolutePath: destination, policy });
346
+ await guardWriteAccess(ctx, {
347
+ toolName: "web_fetch",
348
+ absolutePath: destination,
349
+ policy,
350
+ signal,
351
+ });
347
352
  }
348
353
 
349
354
  try {