@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 +1 -1
- package/src/claude-code/files.ts +20 -6
- package/src/lib/lsp/client.ts +167 -45
- package/src/lib/lsp/lsp.ts +11 -1
- package/src/lib/lsp/rename-tool.ts +13 -8
- package/src/lib/write-guard.ts +18 -4
- package/src/opencode/files.ts +9 -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 +6 -1
package/package.json
CHANGED
package/src/claude-code/files.ts
CHANGED
|
@@ -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
|
-
*
|
|
125
|
-
* offset=0
|
|
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
|
-
|
|
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
|
|
149
|
-
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);
|
|
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,
|
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
|
}
|
|
@@ -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
|
-
|
|
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:
|
|
159
|
+
{ result: (typeof successes)[number]["result"]; candidates: LspPosition[] }
|
|
154
160
|
>();
|
|
155
|
-
for (const
|
|
156
|
-
const
|
|
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:
|
|
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
|
|
package/src/lib/write-guard.ts
CHANGED
|
@@ -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
|
|
|
@@ -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`,
|
package/src/opencode/files.ts
CHANGED
|
@@ -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
|
|
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
|
@@ -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, {
|
|
346
|
+
await guardWriteAccess(ctx, {
|
|
347
|
+
toolName: "web_fetch",
|
|
348
|
+
absolutePath: destination,
|
|
349
|
+
policy,
|
|
350
|
+
signal,
|
|
351
|
+
});
|
|
347
352
|
}
|
|
348
353
|
|
|
349
354
|
try {
|