@trim21/personal-pi-extensions 0.1.631 → 0.1.634
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/edit-match.ts +44 -0
- package/src/claude-code/files.ts +2 -72
- package/src/lib/lsp/client.ts +61 -156
- package/src/lib/lsp/rename.ts +174 -1
- package/src/spawn-agent-progress.ts +214 -0
- package/src/spawn-agent.ts +11 -132
package/package.json
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code 风格的精确替换:只做精确字符串匹配(无模糊策略)。匹配在 CRLF 归一后
|
|
3
|
+
* 进行,写回时恢复原文行尾;new_string 为空且 old_string 不以换行结尾时,连同
|
|
4
|
+
* 紧随其后的换行一起删除,避免留下空行。
|
|
5
|
+
*
|
|
6
|
+
* 只处理非空 `old_string` 的替换:空 `old_string`(创建 / 填充文件)与
|
|
7
|
+
* `old_string === new_string` 的判定在调用方(带工具语义,涉及写盘与提示文案)。
|
|
8
|
+
*
|
|
9
|
+
* 抛错文案是工具契约的一部分(Claude Code 兼容),改动前先看
|
|
10
|
+
* test/claude-code-tools.test.ts 的断言。
|
|
11
|
+
*/
|
|
12
|
+
export function applyExactEdit(
|
|
13
|
+
original: string,
|
|
14
|
+
oldString: string,
|
|
15
|
+
newString: string,
|
|
16
|
+
replaceAll: boolean,
|
|
17
|
+
): string {
|
|
18
|
+
// CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
|
|
19
|
+
const crlfCount = (original.match(/\r\n/g) ?? []).length;
|
|
20
|
+
const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
|
|
21
|
+
const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
|
|
22
|
+
const normalized = original.replaceAll("\r\n", "\n");
|
|
23
|
+
const matches = normalized.split(oldString).length - 1;
|
|
24
|
+
if (matches === 0) {
|
|
25
|
+
throw new Error(`String to replace not found in file.\nString: ${oldString}`);
|
|
26
|
+
}
|
|
27
|
+
if (!replaceAll && matches > 1) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
// 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
|
|
33
|
+
// "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
|
|
34
|
+
// applyEditToFile 的 stripTrailingNewline 语义)
|
|
35
|
+
let searchString = oldString;
|
|
36
|
+
if (newString === "" && !oldString.endsWith("\n") && normalized.includes(oldString + "\n")) {
|
|
37
|
+
searchString = oldString + "\n";
|
|
38
|
+
}
|
|
39
|
+
// split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
|
|
40
|
+
const updated = replaceAll
|
|
41
|
+
? normalized.split(searchString).join(newString)
|
|
42
|
+
: normalized.replace(searchString, () => newString);
|
|
43
|
+
return lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
|
|
44
|
+
}
|
package/src/claude-code/files.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type { ToolPendant } from "../lib/pendant.ts";
|
|
|
31
31
|
import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
|
|
32
32
|
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
33
33
|
import { didYouMean, resolveToolFilePath } from "./common.js";
|
|
34
|
+
import { applyExactEdit } from "./edit-match.js";
|
|
34
35
|
import { convertLeadingTabsToSpaces } from "./edit-utils.js";
|
|
35
36
|
|
|
36
37
|
const SAMPLE_BYTES = 4096;
|
|
@@ -170,47 +171,6 @@ export function formatReadOutput(
|
|
|
170
171
|
return { text, totalLines };
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
function countMatches(content: string, needle: string): number {
|
|
174
|
-
if (needle === "") {
|
|
175
|
-
return 0;
|
|
176
|
-
}
|
|
177
|
-
let count = 0;
|
|
178
|
-
let index = 0;
|
|
179
|
-
while ((index = content.indexOf(needle, index)) !== -1) {
|
|
180
|
-
count++;
|
|
181
|
-
index += needle.length;
|
|
182
|
-
}
|
|
183
|
-
return count;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export function exactReplace(
|
|
187
|
-
content: string,
|
|
188
|
-
oldString: string,
|
|
189
|
-
newString: string,
|
|
190
|
-
replaceAll = false,
|
|
191
|
-
): string {
|
|
192
|
-
if (oldString === newString) {
|
|
193
|
-
throw new Error("No changes to apply: old_string and new_string are identical.");
|
|
194
|
-
}
|
|
195
|
-
if (oldString === "") {
|
|
196
|
-
throw new Error("old_string must not be empty.");
|
|
197
|
-
}
|
|
198
|
-
const matches = countMatches(content, oldString);
|
|
199
|
-
if (matches === 0) {
|
|
200
|
-
throw new Error("String to replace not found in file.");
|
|
201
|
-
}
|
|
202
|
-
if (!replaceAll && matches > 1) {
|
|
203
|
-
throw new Error(
|
|
204
|
-
`Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, provide more context to make old_string unique.`,
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
if (replaceAll) {
|
|
208
|
-
return content.split(oldString).join(newString);
|
|
209
|
-
}
|
|
210
|
-
const index = content.indexOf(oldString);
|
|
211
|
-
return content.slice(0, index) + newString + content.slice(index + oldString.length);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
174
|
export function registerFileTools(
|
|
215
175
|
pi: ExtensionAPI,
|
|
216
176
|
state: ReadsState,
|
|
@@ -483,37 +443,7 @@ export function registerFileTools(
|
|
|
483
443
|
await access(filePath, constants.R_OK | constants.W_OK);
|
|
484
444
|
signal?.throwIfAborted();
|
|
485
445
|
const original = content.toString("utf8");
|
|
486
|
-
|
|
487
|
-
// CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
|
|
488
|
-
const crlfCount = (original.match(/\r\n/g) ?? []).length;
|
|
489
|
-
const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
|
|
490
|
-
const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
|
|
491
|
-
const normalized = original.replaceAll("\r\n", "\n");
|
|
492
|
-
const matches = normalized.split(oldString).length - 1;
|
|
493
|
-
if (matches === 0) {
|
|
494
|
-
throw new Error(`String to replace not found in file.\nString: ${oldString}`);
|
|
495
|
-
}
|
|
496
|
-
if (!replaceAll && matches > 1) {
|
|
497
|
-
throw new Error(
|
|
498
|
-
`Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
|
|
499
|
-
);
|
|
500
|
-
}
|
|
501
|
-
// 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
|
|
502
|
-
// "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
|
|
503
|
-
// applyEditToFile 的 stripTrailingNewline 语义)
|
|
504
|
-
let searchString = oldString;
|
|
505
|
-
if (
|
|
506
|
-
newString === "" &&
|
|
507
|
-
!oldString.endsWith("\n") &&
|
|
508
|
-
normalized.includes(oldString + "\n")
|
|
509
|
-
) {
|
|
510
|
-
searchString = oldString + "\n";
|
|
511
|
-
}
|
|
512
|
-
// split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
|
|
513
|
-
const updated = replaceAll
|
|
514
|
-
? normalized.split(searchString).join(newString)
|
|
515
|
-
: normalized.replace(searchString, () => newString);
|
|
516
|
-
const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
|
|
446
|
+
const restored = applyExactEdit(original, oldString, newString, replaceAll);
|
|
517
447
|
await writeFile(filePath, restored, "utf8");
|
|
518
448
|
const snapshot = snapshotOf(restored);
|
|
519
449
|
state.reads.set(key, snapshot);
|
package/src/lib/lsp/client.ts
CHANGED
|
@@ -65,7 +65,11 @@ import {
|
|
|
65
65
|
|
|
66
66
|
import type { LspServerHandle } from "./adapter.js";
|
|
67
67
|
import { LANGUAGE_EXTENSIONS } from "./language.js";
|
|
68
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
DEFAULT_RENAME_VERIFICATION_TIMING,
|
|
70
|
+
type RenameVerificationTiming,
|
|
71
|
+
verifyRenameCoverage,
|
|
72
|
+
} from "./rename.js";
|
|
69
73
|
import type { FileChange, FileChangeType } from "./watcher.js";
|
|
70
74
|
|
|
71
75
|
/** LSP WatchKind 位掩码(FileSystemWatcher.kind,缺省 create|change|delete)。 */
|
|
@@ -108,66 +112,11 @@ export class LspMethodNotSupportedError extends Error {
|
|
|
108
112
|
}
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
/**
|
|
112
|
-
* rename edit 未覆盖 references 看到的全部文件:服务器索引可能仍在后台加载。
|
|
113
|
-
* 抛出时发生在写盘之前,整个 rename 无副作用,可稍后重试。
|
|
114
|
-
*/
|
|
115
|
-
export class RenameIncompleteError extends Error {
|
|
116
|
-
readonly missing: readonly string[];
|
|
117
|
-
readonly extra: readonly string[];
|
|
118
|
-
constructor(missing: readonly string[], extra: readonly string[] = []) {
|
|
119
|
-
const parts: string[] = [];
|
|
120
|
-
if (missing.length > 0) {
|
|
121
|
-
parts.push(
|
|
122
|
-
`textDocument/references found the symbol in ${missing.length} file(s) ` +
|
|
123
|
-
`that the rename edit does not cover (${missing.join(", ")})`,
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
if (extra.length > 0) {
|
|
127
|
-
parts.push(
|
|
128
|
-
`the rename edit touches ${extra.length} file(s) ` +
|
|
129
|
-
`that textDocument/references did not report (${extra.join(", ")})`,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
if (parts.length === 0) {
|
|
133
|
-
parts.push("the references result has not been stable long enough to trust");
|
|
134
|
-
}
|
|
135
|
-
super(
|
|
136
|
-
`LSP rename incomplete: ${parts.join("; ")}. ` +
|
|
137
|
-
`The server index may still be loading; nothing was modified, retry shortly.`,
|
|
138
|
-
);
|
|
139
|
-
this.missing = missing;
|
|
140
|
-
this.extra = extra;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* rename 覆盖校验的轮询节奏。budgetMs 是 references 收敛 + 重试的总预算;
|
|
146
|
-
* 测试可临时缩小以缩短等待。
|
|
147
|
-
*/
|
|
148
|
-
export const renameVerificationTiming = {
|
|
149
|
-
pollMs: 400,
|
|
150
|
-
budgetMs: 15_000,
|
|
151
|
-
/** ContentModified(-32801) 重试上限:服务器处理期间文档被修改,重发请求即可。 */
|
|
152
|
-
contentModifiedRetries: 3,
|
|
153
|
-
/** 接受结果所需的最少连续一致采样次数。 */
|
|
154
|
-
settleSamples: 3,
|
|
155
|
-
/** 就绪已证实(拿到过当前版本的诊断结论)时的稳定窗口下限(ms)。 */
|
|
156
|
-
stableFloorReadyMs: 400,
|
|
157
|
-
/**
|
|
158
|
-
* 就绪未证实(栅栏只是等满预算放行)时的稳定窗口下限(ms)。服务器加载
|
|
159
|
-
* 项目期间 references 只覆盖已发现的文件,残缺答案能连续多次一致(假稳定),
|
|
160
|
-
* 短窗口会把它误判成最终结果——CI 实测漏改跨文件引用,故要求 references
|
|
161
|
-
* 文件集合至少持续一致这么久才接受。
|
|
162
|
-
*/
|
|
163
|
-
stableFloorUnreadyMs: 4_000,
|
|
164
|
-
};
|
|
165
|
-
|
|
166
115
|
/**
|
|
167
116
|
* ContentModified 语义:请求处理期间 salsa 数据库被文件变更修改,服务器请
|
|
168
117
|
* 客户端重发请求。重发无副作用,直接重试;仅连续超限才向上抛,避免无限循环。
|
|
169
118
|
*/
|
|
170
|
-
async function retryOnContentModified<T>(fn: () => Promise<T
|
|
119
|
+
async function retryOnContentModified<T>(fn: () => Promise<T>, retries: number): Promise<T> {
|
|
171
120
|
for (let attempt = 0; ; attempt++) {
|
|
172
121
|
try {
|
|
173
122
|
return await fn();
|
|
@@ -175,7 +124,7 @@ async function retryOnContentModified<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
175
124
|
if (!(error instanceof ResponseError && error.code === LSPErrorCodes.ContentModified)) {
|
|
176
125
|
throw error;
|
|
177
126
|
}
|
|
178
|
-
if (attempt >=
|
|
127
|
+
if (attempt >= retries) {
|
|
179
128
|
throw error;
|
|
180
129
|
}
|
|
181
130
|
}
|
|
@@ -348,6 +297,8 @@ export interface CreateInput {
|
|
|
348
297
|
initializeTimeoutMs?: number;
|
|
349
298
|
/** 驻留文档上限(LRU 容量,缺省 32);超过时淘汰最久未使用并 didClose。 */
|
|
350
299
|
maxOpenDocuments?: number;
|
|
300
|
+
/** 覆盖 rename 覆盖校验的轮询节奏(缺省见 DEFAULT_RENAME_VERIFICATION_TIMING)。 */
|
|
301
|
+
renameVerificationTiming?: Partial<RenameVerificationTiming>;
|
|
351
302
|
}
|
|
352
303
|
|
|
353
304
|
export interface LspClient {
|
|
@@ -573,6 +524,10 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
573
524
|
input.diagnosticsRequestTimeoutMs ?? clientDefaults.diagnosticsRequestTimeoutMs;
|
|
574
525
|
const initializeTimeoutMs = input.initializeTimeoutMs ?? clientDefaults.initializeTimeoutMs;
|
|
575
526
|
const maxOpenDocuments = input.maxOpenDocuments ?? clientDefaults.maxOpenDocuments;
|
|
527
|
+
const timing: RenameVerificationTiming = {
|
|
528
|
+
...DEFAULT_RENAME_VERIFICATION_TIMING,
|
|
529
|
+
...input.renameVerificationTiming,
|
|
530
|
+
};
|
|
576
531
|
|
|
577
532
|
const connection = createProtocolConnection(
|
|
578
533
|
new StreamMessageReader(input.server.process.stdout),
|
|
@@ -1377,7 +1332,10 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1377
1332
|
signal?: AbortSignal,
|
|
1378
1333
|
): Promise<R> => {
|
|
1379
1334
|
try {
|
|
1380
|
-
return await retryOnContentModified(
|
|
1335
|
+
return await retryOnContentModified(
|
|
1336
|
+
() => sendAbortableRequest(type, message, signal),
|
|
1337
|
+
timing.contentModifiedRetries,
|
|
1338
|
+
);
|
|
1381
1339
|
} catch (error) {
|
|
1382
1340
|
if (error instanceof ResponseError && error.code === ErrorCodes.MethodNotFound) {
|
|
1383
1341
|
throw new LspMethodNotSupportedError(input.serverID, type.method);
|
|
@@ -1477,32 +1435,20 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1477
1435
|
const notRenameable = () =>
|
|
1478
1436
|
new RenameNotPossibleError(`LSP server "${input.serverID}" cannot rename at ${at}`);
|
|
1479
1437
|
|
|
1480
|
-
// references 前置 + rename
|
|
1481
|
-
// 服务器(如 tsserver)可能在项目加载完成前回答,导致 rename 漏掉
|
|
1482
|
-
// 尚未入索引的文件。对策分三层:
|
|
1483
|
-
// 1. 稳定窗口:references 文件集合连续 settleSamples 次一致、且持续
|
|
1484
|
-
// 一致超过稳定下限(就绪未证实用长窗口)才认为收敛,防止"服务器
|
|
1485
|
-
// 根本还没发现某文件"时残缺答案的假稳定被误判为最终结果;
|
|
1486
|
-
// 2. 覆盖校验(missing):references 报告的文件必须都被 rename edit
|
|
1487
|
-
// 覆盖,缺失说明服务器索引落后,抛 RenameIncompleteError;
|
|
1488
|
-
// 3. 一致性校验(extra):rename 触及的文件超出已收敛的 references
|
|
1489
|
-
// 集合,说明两次请求之间项目覆盖在增长(rename 晚于 references,
|
|
1490
|
-
// 索引仍在加载),此时 rename 的结果本身不可信——回到 references
|
|
1491
|
-
// 轮询等重新收敛,再重发 rename 复检;预算耗尽仍不一致时抛
|
|
1492
|
-
// RenameIncompleteError——调用方尚未写盘,整个操作无副作用。
|
|
1493
|
-
// 服务器不支持 references(MethodNotFound)时跳过校验,信任服务器,
|
|
1494
|
-
// 与编辑器行为一致。
|
|
1438
|
+
// references 前置 + rename 双向校验见 verifyRenameCoverage(三层策略的说明在那边)。
|
|
1495
1439
|
const referencesRequest = () =>
|
|
1496
|
-
retryOnContentModified(
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1440
|
+
retryOnContentModified(
|
|
1441
|
+
() =>
|
|
1442
|
+
sendAbortableRequest(
|
|
1443
|
+
ReferencesRequest.type,
|
|
1444
|
+
{
|
|
1445
|
+
textDocument: { uri },
|
|
1446
|
+
position,
|
|
1447
|
+
context: { includeDeclaration: true },
|
|
1448
|
+
},
|
|
1449
|
+
request.signal,
|
|
1450
|
+
),
|
|
1451
|
+
timing.contentModifiedRetries,
|
|
1506
1452
|
);
|
|
1507
1453
|
|
|
1508
1454
|
const toPaths = (locations: { uri: string }[] | null): Set<string> =>
|
|
@@ -1514,16 +1460,18 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1514
1460
|
|
|
1515
1461
|
const sendRename = async (): Promise<WorkspaceEdit | null> => {
|
|
1516
1462
|
try {
|
|
1517
|
-
return await retryOnContentModified(
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1463
|
+
return await retryOnContentModified(
|
|
1464
|
+
() =>
|
|
1465
|
+
sendAbortableRequest(
|
|
1466
|
+
RenameRequest.type,
|
|
1467
|
+
{
|
|
1468
|
+
textDocument: { uri },
|
|
1469
|
+
position,
|
|
1470
|
+
newName: request.newName,
|
|
1471
|
+
},
|
|
1472
|
+
request.signal,
|
|
1473
|
+
),
|
|
1474
|
+
timing.contentModifiedRetries,
|
|
1527
1475
|
);
|
|
1528
1476
|
} catch (error) {
|
|
1529
1477
|
if (error instanceof ResponseError && error.code === ErrorCodes.MethodNotFound) {
|
|
@@ -1537,12 +1485,14 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1537
1485
|
if (hasPrepareProvider) {
|
|
1538
1486
|
let prepared: PrepareRenameResult | null;
|
|
1539
1487
|
try {
|
|
1540
|
-
prepared = await retryOnContentModified(
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1488
|
+
prepared = await retryOnContentModified(
|
|
1489
|
+
() =>
|
|
1490
|
+
sendAbortableRequest(
|
|
1491
|
+
PrepareRenameRequest.type,
|
|
1492
|
+
{ textDocument: { uri }, position },
|
|
1493
|
+
request.signal,
|
|
1494
|
+
),
|
|
1495
|
+
timing.contentModifiedRetries,
|
|
1546
1496
|
);
|
|
1547
1497
|
} catch (error) {
|
|
1548
1498
|
if (error instanceof ResponseError && error.code === ErrorCodes.MethodNotFound) {
|
|
@@ -1572,63 +1522,18 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1572
1522
|
return placeholder === undefined ? { edit } : { edit, placeholder };
|
|
1573
1523
|
}
|
|
1574
1524
|
|
|
1575
|
-
const
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
now: Date.now(),
|
|
1525
|
+
const edit = await verifyRenameCoverage({
|
|
1526
|
+
indexReady,
|
|
1527
|
+
timing,
|
|
1528
|
+
initialPaths: toPaths(locations),
|
|
1529
|
+
refetchPaths: async () => toPaths(await referencesRequest()),
|
|
1530
|
+
sendRename: async () => (await sendRename()) ?? null,
|
|
1531
|
+
sleep: sleepWithSignal,
|
|
1532
|
+
now: () => Date.now(),
|
|
1533
|
+
notRenameable,
|
|
1534
|
+
signal: request.signal,
|
|
1583
1535
|
});
|
|
1584
|
-
|
|
1585
|
-
request.signal?.throwIfAborted();
|
|
1586
|
-
const now = Date.now();
|
|
1587
|
-
const stable = stabilityAcceptable(stability, {
|
|
1588
|
-
now,
|
|
1589
|
-
minSamples: renameVerificationTiming.settleSamples,
|
|
1590
|
-
minStableMs,
|
|
1591
|
-
});
|
|
1592
|
-
const expired = now >= deadline;
|
|
1593
|
-
if (stable || expired) {
|
|
1594
|
-
const edit = await sendRename();
|
|
1595
|
-
if (!edit) {
|
|
1596
|
-
throw notRenameable();
|
|
1597
|
-
}
|
|
1598
|
-
const editPaths = editFilePaths(edit);
|
|
1599
|
-
const missing: string[] = [];
|
|
1600
|
-
const extra: string[] = [];
|
|
1601
|
-
for (const path of stability.paths) {
|
|
1602
|
-
if (!editPaths.has(path)) {
|
|
1603
|
-
missing.push(path);
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
for (const path of editPaths) {
|
|
1607
|
-
if (!stability.paths.has(path)) {
|
|
1608
|
-
extra.push(path);
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
if (missing.length === 0 && extra.length === 0) {
|
|
1612
|
-
if (stable) {
|
|
1613
|
-
return placeholder === undefined ? { edit } : { edit, placeholder };
|
|
1614
|
-
}
|
|
1615
|
-
// 双向一致但稳定窗口未达标:索引可能仍在加载、残缺答案假稳定,
|
|
1616
|
-
// 宁可报可重试的不完整错误,也不把残缺结果当成功写盘。
|
|
1617
|
-
throw new RenameIncompleteError([], []);
|
|
1618
|
-
}
|
|
1619
|
-
if (expired || missing.length > 0) {
|
|
1620
|
-
throw new RenameIncompleteError(missing, extra);
|
|
1621
|
-
}
|
|
1622
|
-
// rename 报出 references 没有的文件:references 快照已过时,
|
|
1623
|
-
// 继续轮询到重新收敛后再重发 rename 复检(预算耗尽则向上抛)。
|
|
1624
|
-
}
|
|
1625
|
-
await sleepWithSignal(renameVerificationTiming.pollMs, request.signal);
|
|
1626
|
-
stability = trackStability({
|
|
1627
|
-
previous: stability,
|
|
1628
|
-
paths: toPaths(await referencesRequest()),
|
|
1629
|
-
now: Date.now(),
|
|
1630
|
-
});
|
|
1631
|
-
}
|
|
1536
|
+
return placeholder === undefined ? { edit } : { edit, placeholder };
|
|
1632
1537
|
},
|
|
1633
1538
|
async definition(request: InspectPositionRequest): Promise<InspectLocation[]> {
|
|
1634
1539
|
const { uri, position } = await preparePositionRequest(request);
|
package/src/lib/lsp/rename.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* - lineCandidates:character 缺省时在一行内按词边界枚举候选位置,
|
|
7
7
|
* 供逐候选探测消歧;
|
|
8
8
|
* - canonicalizeEdit:把两次 rename 的结果归一化成稳定字符串,
|
|
9
|
-
*
|
|
9
|
+
* 比较它们是否指向同一个符号(同名歧义消解);
|
|
10
|
+
* - verifyRenameCoverage:等 references 的稳定窗口收敛,再双向校验 rename edit
|
|
11
|
+
* 的覆盖(missing / extra),不满足时抛 RenameIncompleteError。
|
|
10
12
|
*
|
|
11
13
|
* 位置语义按 LSP 规范:0-based 行列,character 为 UTF-16 code unit,
|
|
12
14
|
* 行内容不含行结束符(CRLF 的 \r 不计入列号)。
|
|
@@ -243,6 +245,177 @@ export function stabilityAcceptable(
|
|
|
243
245
|
return run.samples >= input.minSamples && input.now - run.since >= input.minStableMs;
|
|
244
246
|
}
|
|
245
247
|
|
|
248
|
+
/**
|
|
249
|
+
* rename edit 未覆盖 references 看到的全部文件:服务器索引可能仍在后台加载。
|
|
250
|
+
* 抛出时发生在写盘之前,整个 rename 无副作用,可稍后重试。
|
|
251
|
+
*/
|
|
252
|
+
export class RenameIncompleteError extends Error {
|
|
253
|
+
readonly missing: readonly string[];
|
|
254
|
+
readonly extra: readonly string[];
|
|
255
|
+
constructor(missing: readonly string[], extra: readonly string[] = []) {
|
|
256
|
+
const parts: string[] = [];
|
|
257
|
+
if (missing.length > 0) {
|
|
258
|
+
parts.push(
|
|
259
|
+
`textDocument/references found the symbol in ${missing.length} file(s) ` +
|
|
260
|
+
`that the rename edit does not cover (${missing.join(", ")})`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (extra.length > 0) {
|
|
264
|
+
parts.push(
|
|
265
|
+
`the rename edit touches ${extra.length} file(s) ` +
|
|
266
|
+
`that textDocument/references did not report (${extra.join(", ")})`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (parts.length === 0) {
|
|
270
|
+
parts.push("the references result has not been stable long enough to trust");
|
|
271
|
+
}
|
|
272
|
+
super(
|
|
273
|
+
`LSP rename incomplete: ${parts.join("; ")}. ` +
|
|
274
|
+
`The server index may still be loading; nothing was modified, retry shortly.`,
|
|
275
|
+
);
|
|
276
|
+
this.missing = missing;
|
|
277
|
+
this.extra = extra;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* rename 覆盖校验的轮询节奏。budgetMs 是 references 收敛 + 重试的总预算;
|
|
283
|
+
* 缺省见 DEFAULT_RENAME_VERIFICATION_TIMING,client 与测试按需覆盖。
|
|
284
|
+
*/
|
|
285
|
+
export interface RenameVerificationTiming {
|
|
286
|
+
pollMs: number;
|
|
287
|
+
budgetMs: number;
|
|
288
|
+
/** ContentModified(-32801) 重试上限:服务器处理期间文档被修改,重发请求即可。 */
|
|
289
|
+
contentModifiedRetries: number;
|
|
290
|
+
/** 接受结果所需的最少连续一致采样次数。 */
|
|
291
|
+
settleSamples: number;
|
|
292
|
+
/** 就绪已证实(拿到过当前版本的诊断结论)时的稳定窗口下限(ms)。 */
|
|
293
|
+
stableFloorReadyMs: number;
|
|
294
|
+
/**
|
|
295
|
+
* 就绪未证实(栅栏只是等满预算放行)时的稳定窗口下限(ms)。服务器加载
|
|
296
|
+
* 项目期间 references 只覆盖已发现的文件,残缺答案能连续多次一致(假稳定),
|
|
297
|
+
* 短窗口会把它误判成最终结果——CI 实测漏改跨文件引用,故要求 references
|
|
298
|
+
* 文件集合至少持续一致这么久才接受。
|
|
299
|
+
*/
|
|
300
|
+
stableFloorUnreadyMs: number;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** rename 覆盖校验的缺省节奏(每个 client 可用创建参数局部覆盖)。 */
|
|
304
|
+
export const DEFAULT_RENAME_VERIFICATION_TIMING: RenameVerificationTiming = {
|
|
305
|
+
pollMs: 400,
|
|
306
|
+
budgetMs: 15_000,
|
|
307
|
+
contentModifiedRetries: 3,
|
|
308
|
+
settleSamples: 3,
|
|
309
|
+
stableFloorReadyMs: 400,
|
|
310
|
+
stableFloorUnreadyMs: 4_000,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/** verifyRenameCoverage 的输入:依赖全部注入,收敛序列与预算可直接构造。 */
|
|
314
|
+
export interface RenameCoverageOptions {
|
|
315
|
+
/** 就绪栅栏:是否已证实拿到当前版本的诊断结论(决定稳定窗口下限)。 */
|
|
316
|
+
indexReady: boolean;
|
|
317
|
+
timing: RenameVerificationTiming;
|
|
318
|
+
/** 进入校验前已采到的那一份 references 文件集合。 */
|
|
319
|
+
initialPaths: ReadonlySet<string>;
|
|
320
|
+
/** 后续采样:再请求一次 references 并给出文件集合。 */
|
|
321
|
+
refetchPaths: () => Promise<ReadonlySet<string>>;
|
|
322
|
+
/** 发一次 rename;返回 null 表示服务器拒绝重命名。 */
|
|
323
|
+
sendRename: () => Promise<WorkspaceEdit | null>;
|
|
324
|
+
/** 等待一次轮询间隔。 */
|
|
325
|
+
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
326
|
+
/** 当前时间戳(ms)。 */
|
|
327
|
+
now: () => number;
|
|
328
|
+
/** 构造「该位置不可重命名」错误:消息含 serverID 与位置,由调用方提供。 */
|
|
329
|
+
notRenameable: () => Error;
|
|
330
|
+
signal?: AbortSignal;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 等 references 收敛并双向校验 rename 覆盖,返回 edit;不满足覆盖要求时抛
|
|
335
|
+
* RenameIncompleteError。
|
|
336
|
+
*
|
|
337
|
+
* references 前置 + rename 双向校验:LSP 没有标准化的"索引完成"信号,
|
|
338
|
+
* 服务器(如 tsserver)可能在项目加载完成前回答,导致 rename 漏掉
|
|
339
|
+
* 尚未入索引的文件。对策分三层:
|
|
340
|
+
* 1. 稳定窗口:references 文件集合连续 settleSamples 次一致、且持续
|
|
341
|
+
* 一致超过稳定下限(就绪未证实用长窗口)才认为收敛,防止"服务器
|
|
342
|
+
* 根本还没发现某文件"时残缺答案的假稳定被误判为最终结果;
|
|
343
|
+
* 2. 覆盖校验(missing):references 报告的文件必须都被 rename edit
|
|
344
|
+
* 覆盖,缺失说明服务器索引落后,抛 RenameIncompleteError;
|
|
345
|
+
* 3. 一致性校验(extra):rename 触及的文件超出已收敛的 references
|
|
346
|
+
* 集合,说明两次请求之间项目覆盖在增长(rename 晚于 references,
|
|
347
|
+
* 索引仍在加载),此时 rename 的结果本身不可信——回到 references
|
|
348
|
+
* 轮询等重新收敛,再重发 rename 复检;预算耗尽仍不一致时抛
|
|
349
|
+
* RenameIncompleteError——调用方尚未写盘,整个操作无副作用。
|
|
350
|
+
*
|
|
351
|
+
* 首次采样由调用方发起(initialPaths),本函数只负责后续的 refetchPaths
|
|
352
|
+
* 采样,所以请求次数与调用方内联轮询时逐次一致;服务器不支持
|
|
353
|
+
* references(MethodNotFound)时由调用方跳过本校验,信任服务器。
|
|
354
|
+
*
|
|
355
|
+
* 判定顺序是行为的一部分:双向一致但稳定窗口未达标时抛的是
|
|
356
|
+
* RenameIncompleteError([], [])(残缺答案假稳定),而非带上 missing 的错误。
|
|
357
|
+
*/
|
|
358
|
+
export async function verifyRenameCoverage(options: RenameCoverageOptions): Promise<WorkspaceEdit> {
|
|
359
|
+
const {
|
|
360
|
+
indexReady,
|
|
361
|
+
timing,
|
|
362
|
+
initialPaths,
|
|
363
|
+
refetchPaths,
|
|
364
|
+
sendRename,
|
|
365
|
+
sleep,
|
|
366
|
+
now,
|
|
367
|
+
notRenameable,
|
|
368
|
+
signal,
|
|
369
|
+
} = options;
|
|
370
|
+
const minStableMs = indexReady ? timing.stableFloorReadyMs : timing.stableFloorUnreadyMs;
|
|
371
|
+
const deadline = now() + timing.budgetMs;
|
|
372
|
+
let stability = trackStability({ previous: undefined, paths: initialPaths, now: now() });
|
|
373
|
+
for (;;) {
|
|
374
|
+
signal?.throwIfAborted();
|
|
375
|
+
const at = now();
|
|
376
|
+
const stable = stabilityAcceptable(stability, {
|
|
377
|
+
now: at,
|
|
378
|
+
minSamples: timing.settleSamples,
|
|
379
|
+
minStableMs,
|
|
380
|
+
});
|
|
381
|
+
const expired = at >= deadline;
|
|
382
|
+
if (stable || expired) {
|
|
383
|
+
const edit = await sendRename();
|
|
384
|
+
if (!edit) {
|
|
385
|
+
throw notRenameable();
|
|
386
|
+
}
|
|
387
|
+
const editPaths = editFilePaths(edit);
|
|
388
|
+
const missing: string[] = [];
|
|
389
|
+
const extra: string[] = [];
|
|
390
|
+
for (const path of stability.paths) {
|
|
391
|
+
if (!editPaths.has(path)) {
|
|
392
|
+
missing.push(path);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
for (const path of editPaths) {
|
|
396
|
+
if (!stability.paths.has(path)) {
|
|
397
|
+
extra.push(path);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (missing.length === 0 && extra.length === 0) {
|
|
401
|
+
if (stable) {
|
|
402
|
+
return edit;
|
|
403
|
+
}
|
|
404
|
+
// 双向一致但稳定窗口未达标:索引可能仍在加载、残缺答案假稳定,
|
|
405
|
+
// 宁可报可重试的不完整错误,也不把残缺结果当成功写盘。
|
|
406
|
+
throw new RenameIncompleteError([], []);
|
|
407
|
+
}
|
|
408
|
+
if (expired || missing.length > 0) {
|
|
409
|
+
throw new RenameIncompleteError(missing, extra);
|
|
410
|
+
}
|
|
411
|
+
// rename 报出 references 没有的文件:references 快照已过时,
|
|
412
|
+
// 继续轮询到重新收敛后再重发 rename 复检(预算耗尽则向上抛)。
|
|
413
|
+
}
|
|
414
|
+
await sleep(timing.pollMs, signal);
|
|
415
|
+
stability = trackStability({ previous: stability, paths: await refetchPaths(), now: now() });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
246
419
|
/**
|
|
247
420
|
* 一行内 `symbol` 的候选位置(0-based,列相对行首):
|
|
248
421
|
* - `character` 缺省:枚举行内与 `symbol` 相同的词出现位置;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress state machine for the `spawn_agent` tool.
|
|
3
|
+
*
|
|
4
|
+
* The parent sees a rolling log: `tool: <name>` lines for tool calls and
|
|
5
|
+
* `text: <content>` lines for completed text blocks, keeping the last
|
|
6
|
+
* `MAX_PROGRESS_LINES` lines. Consecutive tool calls are merged into a single
|
|
7
|
+
* `tool:` line (`read x 2, glob`) and the merged content is folded to the
|
|
8
|
+
* first/last 9 chars joined by `…`, so a burst of tool calls or a long text
|
|
9
|
+
* block does not flood the window. Only a text block starts a new line —
|
|
10
|
+
* thinking is a transient status line, so it does not break the merge across
|
|
11
|
+
* turn boundaries. Line content is sanitized first: markdown marker characters
|
|
12
|
+
* are stripped and whitespace (including newlines) is collapsed to single
|
|
13
|
+
* spaces, so one log entry is always exactly one rendered line.
|
|
14
|
+
*
|
|
15
|
+
* The rendered panel is the window, then an optional transient
|
|
16
|
+
* `thinking ( N chars )` line while the model is thinking, then a fixed footer
|
|
17
|
+
* line: the subagent name as a code span (`` `scout` ``) followed by the live
|
|
18
|
+
* usage stats when there are any. The thinking line and the footer ride
|
|
19
|
+
* outside the rolling window, so the footer is never trimmed. The model name
|
|
20
|
+
* on the footer is the model the session actually uses, which differs from the
|
|
21
|
+
* frontmatter string when the declared model resolved to nothing and the SDK
|
|
22
|
+
* fell back to a default.
|
|
23
|
+
*
|
|
24
|
+
* State is held in the closure of a `createSubagentProgress` instance, one per
|
|
25
|
+
* run. The `noteX` methods only mutate state and return nothing; pushing the
|
|
26
|
+
* rendered text to the host (`onUpdate`) is the caller's job, so the caller
|
|
27
|
+
* decides when a state change is worth a refresh.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Progress log keeps only the most recent lines (rolling window).
|
|
32
|
+
* 展开后面板最多 6 行内容:4 行日志 + 可能的一行瞬态 thinking + 固定的 metadata 行。
|
|
33
|
+
*/
|
|
34
|
+
const MAX_PROGRESS_LINES = 4;
|
|
35
|
+
/** Progress line content (without the `tool:` / `text:` prefix) is capped at 21 chars; longer text is folded to the first/last 9 chars joined by ` … `. */
|
|
36
|
+
const MAX_PROGRESS_CHARS_PER_LINE = 21;
|
|
37
|
+
/**
|
|
38
|
+
* 进度内容会被 pi 按 markdown 渲染,这些标记字符会改变显示效果(代码块、粗体、
|
|
39
|
+
* 链接、标题等),因此在进日志前统一删掉。
|
|
40
|
+
*/
|
|
41
|
+
const PROGRESS_MARKDOWN_MARKERS_RE = /[`*_~[\]<>#|]/g;
|
|
42
|
+
|
|
43
|
+
/** 进度面板要展示的运行统计(`runAgent` 的 `result.usage`)。 */
|
|
44
|
+
export interface ProgressUsage {
|
|
45
|
+
turns: number;
|
|
46
|
+
cost: number;
|
|
47
|
+
contextTokens: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 一次子代理运行的进度状态机:改状态用 `noteX`,取文本用 `render`。 */
|
|
51
|
+
export interface SubagentProgress {
|
|
52
|
+
/** 工具调用开始:合并进当前 `tool:` 行。 */
|
|
53
|
+
noteToolCall(rawName: string): void;
|
|
54
|
+
/** 一个完成的 text 块:作为 `text:` 行写入并打断工具合并。 */
|
|
55
|
+
noteTextBlock(content: string): void;
|
|
56
|
+
/** thinking 开始:打开瞬态行(计数从 0 开始)。 */
|
|
57
|
+
thinkingStart(): void;
|
|
58
|
+
/** thinking 增量:累加字符数。 */
|
|
59
|
+
thinkingDelta(length: number): void;
|
|
60
|
+
/** thinking 结束:关闭瞬态行。 */
|
|
61
|
+
thinkingEnd(): void;
|
|
62
|
+
/** 渲染面板:滚动窗口 + 瞬态 thinking 行 + footer。 */
|
|
63
|
+
render(usage: ProgressUsage, model?: string): string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fold over-long progress line content: keep the first/last 9 chars joined by
|
|
68
|
+
* ` … ` (space, ellipsis, space), so the folded line never exceeds
|
|
69
|
+
* `MAX_PROGRESS_CHARS_PER_LINE` chars (9 + 3 + 9 = 21). Shorter text is
|
|
70
|
+
* returned as-is.
|
|
71
|
+
*/
|
|
72
|
+
function foldProgressLine(text: string): string {
|
|
73
|
+
if (text.length <= MAX_PROGRESS_CHARS_PER_LINE) {
|
|
74
|
+
return text;
|
|
75
|
+
}
|
|
76
|
+
const keep = Math.floor((MAX_PROGRESS_CHARS_PER_LINE - 3) / 2);
|
|
77
|
+
return `${text.slice(0, keep)} … ${text.slice(-keep)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 进度行是「单行内容 + markdown 渲染」:内容里的换行会打乱按行滚动的窗口,
|
|
82
|
+
* markdown 标记会改变渲染效果。先删掉标记字符,再把换行/制表符/连续空格折成
|
|
83
|
+
* 单个空格并去掉首尾空白,保证一条日志恒为一行。
|
|
84
|
+
*/
|
|
85
|
+
function sanitizeProgressLine(text: string): string {
|
|
86
|
+
return text.replaceAll(PROGRESS_MARKDOWN_MARKERS_RE, "").replaceAll(/\s+/g, " ").trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* token 计数的可读格式。进度面板 footer 与 spawn_agent 的输出截断提示共用,
|
|
91
|
+
* 改动会同时影响两处。
|
|
92
|
+
*/
|
|
93
|
+
export function formatTokens(count: number): string {
|
|
94
|
+
if (count < 1000) {
|
|
95
|
+
return count.toString();
|
|
96
|
+
}
|
|
97
|
+
if (count < 10_000) {
|
|
98
|
+
return `${(count / 1000).toFixed(1)}k`;
|
|
99
|
+
}
|
|
100
|
+
if (count < 1_000_000) {
|
|
101
|
+
return `${Math.round(count / 1000)}k`;
|
|
102
|
+
}
|
|
103
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatUsageStats(usage: ProgressUsage, model?: string): string {
|
|
107
|
+
const parts: string[] = [];
|
|
108
|
+
if (usage.turns) {
|
|
109
|
+
parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
110
|
+
}
|
|
111
|
+
if (usage.cost) {
|
|
112
|
+
parts.push(`$${usage.cost.toFixed(4)}`);
|
|
113
|
+
}
|
|
114
|
+
if (usage.contextTokens > 0) {
|
|
115
|
+
parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
116
|
+
}
|
|
117
|
+
if (model) {
|
|
118
|
+
parts.push(model);
|
|
119
|
+
}
|
|
120
|
+
return parts.join(" ");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toolSegment(name: string, count: number): string {
|
|
124
|
+
return count > 1 ? `${name} x ${count}` : name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create the progress state machine for one subagent run.
|
|
129
|
+
*
|
|
130
|
+
* `maxLines` only controls the rolling window of log lines — the transient
|
|
131
|
+
* thinking line and the footer ride outside it. Defaults to
|
|
132
|
+
* `MAX_PROGRESS_LINES`.
|
|
133
|
+
*/
|
|
134
|
+
export function createSubagentProgress(options: {
|
|
135
|
+
/** 面板标题(子代理名)。 */
|
|
136
|
+
name: string;
|
|
137
|
+
/** 滚动窗口行数,缺省 4。 */
|
|
138
|
+
maxLines?: number;
|
|
139
|
+
}): SubagentProgress {
|
|
140
|
+
const maxLines = options.maxLines ?? MAX_PROGRESS_LINES;
|
|
141
|
+
let logLines: string[] = [];
|
|
142
|
+
// 工具调用行合并:连续的 tool_execution_start 事件合并在同一 `tool:` 行
|
|
143
|
+
// (如 `tool: read x 2, glob`),相同工具名连续出现时计为 `name x N`,
|
|
144
|
+
// 不同名按调用顺序罗列;只有写入日志行的事件(text 块)打断合并,thinking
|
|
145
|
+
// 不写日志行、也不打断合并。
|
|
146
|
+
let toolLineSegments: string[] = [];
|
|
147
|
+
let toolLine: { name: string; count: number } | undefined;
|
|
148
|
+
// 思考中状态:thinkingStart 打开、thinkingDelta 累计字符数、thinkingEnd
|
|
149
|
+
// 关闭;非 undefined 时 render 在 footer 上方插一行瞬态 thinking 状态。
|
|
150
|
+
let thinkingChars: number | undefined;
|
|
151
|
+
|
|
152
|
+
function pushLogLine(line: string) {
|
|
153
|
+
logLines.push(line);
|
|
154
|
+
if (logLines.length > maxLines) {
|
|
155
|
+
logLines = logLines.slice(-maxLines);
|
|
156
|
+
}
|
|
157
|
+
// 写进日志的新行都会打断工具调用合并,下一批调用另起一行。
|
|
158
|
+
toolLine = undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function appendToolLine(rawName: string) {
|
|
162
|
+
const name = sanitizeProgressLine(rawName);
|
|
163
|
+
const firstInBatch = toolLine === undefined;
|
|
164
|
+
if (toolLine === undefined) {
|
|
165
|
+
toolLineSegments = [];
|
|
166
|
+
toolLine = { name, count: 1 };
|
|
167
|
+
} else if (toolLine.name === name) {
|
|
168
|
+
toolLine.count++;
|
|
169
|
+
} else {
|
|
170
|
+
toolLineSegments.push(toolSegment(toolLine.name, toolLine.count));
|
|
171
|
+
toolLine = { name, count: 1 };
|
|
172
|
+
}
|
|
173
|
+
const parts = [...toolLineSegments, toolSegment(toolLine.name, toolLine.count)].join(", ");
|
|
174
|
+
const line = `tool: ${foldProgressLine(parts)}`;
|
|
175
|
+
if (firstInBatch) {
|
|
176
|
+
logLines.push(line);
|
|
177
|
+
if (logLines.length > maxLines) {
|
|
178
|
+
logLines = logLines.slice(-maxLines);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
logLines[logLines.length - 1] = line;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
noteToolCall: appendToolLine,
|
|
187
|
+
noteTextBlock(content) {
|
|
188
|
+
pushLogLine(`text: ${foldProgressLine(sanitizeProgressLine(content))}`);
|
|
189
|
+
},
|
|
190
|
+
thinkingStart() {
|
|
191
|
+
thinkingChars = 0;
|
|
192
|
+
},
|
|
193
|
+
thinkingDelta(length) {
|
|
194
|
+
thinkingChars = (thinkingChars ?? 0) + length;
|
|
195
|
+
},
|
|
196
|
+
thinkingEnd() {
|
|
197
|
+
thinkingChars = undefined;
|
|
198
|
+
},
|
|
199
|
+
render(usage, model) {
|
|
200
|
+
// 最后一行固定是「子代理名 + 运行中统计」:名字用 code span 标出,进度流里
|
|
201
|
+
// 一眼能看出属于哪个 subagent;usage 与它同行,TUI 始终能看到实时 token 开销。
|
|
202
|
+
// 这行位于滚动窗口之外,因此永远不会被挤掉。
|
|
203
|
+
const usageLine = formatUsageStats(usage, model);
|
|
204
|
+
const name = sanitizeProgressLine(options.name);
|
|
205
|
+
const footer = usageLine ? `\`${name}\` ${usageLine}` : `\`${name}\``;
|
|
206
|
+
const lines = [...logLines];
|
|
207
|
+
if (thinkingChars !== undefined) {
|
|
208
|
+
lines.push(`thinking ( ${thinkingChars} chars )`);
|
|
209
|
+
}
|
|
210
|
+
lines.push(footer);
|
|
211
|
+
return lines.join("\n");
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
package/src/spawn-agent.ts
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* returns its final output to the parent model. Progress is streamed through
|
|
13
13
|
* `onUpdate`, the same channel the built-in bash tool uses for live output.
|
|
14
14
|
* Progress is a rolling log: `tool: <name>` lines for tool calls and
|
|
15
|
-
* `text: <content>` lines for completed text blocks, keeping the last
|
|
16
|
-
* `MAX_PROGRESS_LINES`
|
|
15
|
+
* `text: <content>` lines for completed text blocks, keeping only the last few
|
|
16
|
+
* lines (`MAX_PROGRESS_LINES` in spawn-agent-progress.ts). Consecutive tool calls are merged into a
|
|
17
17
|
* single `tool:` line (`read x 2, glob`) and over-long line content is
|
|
18
18
|
* folded to the first/last 9 chars joined by `…`, so a burst of tool calls
|
|
19
19
|
* or a long text block does not flood the window; only a text block starts
|
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
formatAgentList,
|
|
80
80
|
loadSpawnAgentConfig,
|
|
81
81
|
} from "./spawn-agent-agents.js";
|
|
82
|
+
import { createSubagentProgress, formatTokens } from "./spawn-agent-progress.js";
|
|
82
83
|
|
|
83
84
|
// ── constants ────────────────────────────────────────────────────────────────
|
|
84
85
|
|
|
@@ -96,18 +97,6 @@ export const SUBAGENT_DEFAULT_SANDBOX: BwrapConfig = completeBwrapConfig({
|
|
|
96
97
|
fs: { mode: "readonly" },
|
|
97
98
|
network: { mode: "block" },
|
|
98
99
|
});
|
|
99
|
-
/**
|
|
100
|
-
* Progress log keeps only the most recent lines (rolling window).
|
|
101
|
-
* 展开后面板最多 6 行内容:4 行日志 + 可能的一行瞬态 thinking + 固定的 metadata 行。
|
|
102
|
-
*/
|
|
103
|
-
const MAX_PROGRESS_LINES = 4;
|
|
104
|
-
/** Progress line content (without the `tool:` / `text:` prefix) is capped at 21 chars; longer text is folded to the first/last 9 chars joined by ` … `. */
|
|
105
|
-
const MAX_PROGRESS_CHARS_PER_LINE = 21;
|
|
106
|
-
/**
|
|
107
|
-
* 进度内容会被 pi 按 markdown 渲染,这些标记字符会改变显示效果(代码块、粗体、
|
|
108
|
-
* 链接、标题等),因此在进日志前统一删掉。
|
|
109
|
-
*/
|
|
110
|
-
const PROGRESS_MARKDOWN_MARKERS_RE = /[`*_~[\]<>#|]/g;
|
|
111
100
|
/** 错误消息里 stderr 的展示上限。 */
|
|
112
101
|
const MAX_STDERR_ERROR_BYTES = 4 * 1024;
|
|
113
102
|
/** 全局默认配置:~/.pi/agent/spawn-agent.json,字段可被 frontmatter 覆盖。 */
|
|
@@ -231,59 +220,6 @@ export function formatSubagentError(result: SubagentResult): { reason: string; m
|
|
|
231
220
|
return { reason, message: parts.length > 0 ? parts.join("\n") : "(no output)" };
|
|
232
221
|
}
|
|
233
222
|
|
|
234
|
-
/**
|
|
235
|
-
* Fold over-long progress line content: keep the first/last 9 chars joined by
|
|
236
|
-
* ` … ` (space, ellipsis, space), so the folded line never exceeds
|
|
237
|
-
* `MAX_PROGRESS_CHARS_PER_LINE` chars (9 + 3 + 9 = 21). Shorter text is
|
|
238
|
-
* returned as-is.
|
|
239
|
-
*/
|
|
240
|
-
function foldProgressLine(text: string): string {
|
|
241
|
-
if (text.length <= MAX_PROGRESS_CHARS_PER_LINE) {
|
|
242
|
-
return text;
|
|
243
|
-
}
|
|
244
|
-
const keep = Math.floor((MAX_PROGRESS_CHARS_PER_LINE - 3) / 2);
|
|
245
|
-
return `${text.slice(0, keep)} … ${text.slice(-keep)}`;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* 进度行是「单行内容 + markdown 渲染」:内容里的换行会打乱按行滚动的窗口,
|
|
250
|
-
* markdown 标记会改变渲染效果。先删掉标记字符,再把换行/制表符/连续空格折成
|
|
251
|
-
* 单个空格并去掉首尾空白,保证一条日志恒为一行。
|
|
252
|
-
*/
|
|
253
|
-
function sanitizeProgressLine(text: string): string {
|
|
254
|
-
return text.replaceAll(PROGRESS_MARKDOWN_MARKERS_RE, "").replaceAll(/\s+/g, " ").trim();
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function formatTokens(count: number): string {
|
|
258
|
-
if (count < 1000) {
|
|
259
|
-
return count.toString();
|
|
260
|
-
}
|
|
261
|
-
if (count < 10_000) {
|
|
262
|
-
return `${(count / 1000).toFixed(1)}k`;
|
|
263
|
-
}
|
|
264
|
-
if (count < 1_000_000) {
|
|
265
|
-
return `${Math.round(count / 1000)}k`;
|
|
266
|
-
}
|
|
267
|
-
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function formatUsageStats(usage: UsageStats, model?: string): string {
|
|
271
|
-
const parts: string[] = [];
|
|
272
|
-
if (usage.turns) {
|
|
273
|
-
parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
274
|
-
}
|
|
275
|
-
if (usage.cost) {
|
|
276
|
-
parts.push(`$${usage.cost.toFixed(4)}`);
|
|
277
|
-
}
|
|
278
|
-
if (usage.contextTokens > 0) {
|
|
279
|
-
parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
280
|
-
}
|
|
281
|
-
if (model) {
|
|
282
|
-
parts.push(model);
|
|
283
|
-
}
|
|
284
|
-
return parts.join(" ");
|
|
285
|
-
}
|
|
286
|
-
|
|
287
223
|
/** 子 agent 结果的折叠面板 markdown:父 agent 的 prompt 与父 agent 看到的结果。 */
|
|
288
224
|
function formatPendantMarkdown(task: string, response: string): string {
|
|
289
225
|
return `# prompt:\n${task.trim()}\n# response\n${response.trim()}`;
|
|
@@ -375,10 +311,6 @@ export function resolveModel(
|
|
|
375
311
|
|
|
376
312
|
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
|
377
313
|
|
|
378
|
-
function toolSegment(name: string, count: number): string {
|
|
379
|
-
return count > 1 ? `${name} x ${count}` : name;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
314
|
/** The subset of AgentSession runAgent relies on (injectable for tests). */
|
|
383
315
|
export interface SubagentSession {
|
|
384
316
|
agent: { state: { messages: AgentMessage[] } };
|
|
@@ -478,65 +410,11 @@ export async function runAgent(
|
|
|
478
410
|
// fallback 到默认模型,回显配置原文会掩盖这次 fallback。
|
|
479
411
|
result.model = session.model?.id;
|
|
480
412
|
|
|
481
|
-
|
|
482
|
-
// 工具调用行合并:连续的 tool_execution_start 事件合并在同一 `tool:` 行
|
|
483
|
-
// (如 `tool: read x 2, glob`),相同工具名连续出现时计为 `name x N`,
|
|
484
|
-
// 不同名按调用顺序罗列;只有写入日志行的事件(text 块)打断合并,thinking
|
|
485
|
-
// 不写日志行、也不打断合并。
|
|
486
|
-
let toolLineSegments: string[] = [];
|
|
487
|
-
let toolLine: { name: string; count: number } | undefined;
|
|
488
|
-
// 思考中状态:thinking_start 打开、thinking_delta 累计字符数、thinking_end
|
|
489
|
-
// 关闭;非 undefined 时 emitUpdate 在 footer 上方插一行瞬态 thinking 状态。
|
|
490
|
-
let thinkingChars: number | undefined;
|
|
491
|
-
|
|
492
|
-
const pushLogLine = (line: string) => {
|
|
493
|
-
logLines.push(line);
|
|
494
|
-
if (logLines.length > MAX_PROGRESS_LINES) {
|
|
495
|
-
logLines = logLines.slice(-MAX_PROGRESS_LINES);
|
|
496
|
-
}
|
|
497
|
-
// 写进日志的新行都会打断工具调用合并,下一批调用另起一行。
|
|
498
|
-
toolLine = undefined;
|
|
499
|
-
};
|
|
500
|
-
|
|
501
|
-
const appendToolLine = (rawName: string) => {
|
|
502
|
-
const name = sanitizeProgressLine(rawName);
|
|
503
|
-
const firstInBatch = toolLine === undefined;
|
|
504
|
-
if (toolLine === undefined) {
|
|
505
|
-
toolLineSegments = [];
|
|
506
|
-
toolLine = { name, count: 1 };
|
|
507
|
-
} else if (toolLine.name === name) {
|
|
508
|
-
toolLine.count++;
|
|
509
|
-
} else {
|
|
510
|
-
toolLineSegments.push(toolSegment(toolLine.name, toolLine.count));
|
|
511
|
-
toolLine = { name, count: 1 };
|
|
512
|
-
}
|
|
513
|
-
const parts = [...toolLineSegments, toolSegment(toolLine.name, toolLine.count)].join(", ");
|
|
514
|
-
const line = `tool: ${foldProgressLine(parts)}`;
|
|
515
|
-
if (firstInBatch) {
|
|
516
|
-
logLines.push(line);
|
|
517
|
-
if (logLines.length > MAX_PROGRESS_LINES) {
|
|
518
|
-
logLines = logLines.slice(-MAX_PROGRESS_LINES);
|
|
519
|
-
}
|
|
520
|
-
} else {
|
|
521
|
-
logLines[logLines.length - 1] = line;
|
|
522
|
-
}
|
|
523
|
-
emitUpdate();
|
|
524
|
-
};
|
|
413
|
+
const progress = createSubagentProgress({ name: result.agent });
|
|
525
414
|
|
|
526
415
|
const emitUpdate = () => {
|
|
527
|
-
// 最后一行固定是「子代理名 + 运行中统计」:名字用 code span 标出,进度流里
|
|
528
|
-
// 一眼能看出属于哪个 subagent;usage 与它同行,TUI 始终能看到实时 token 开销。
|
|
529
|
-
// 这行位于滚动窗口之外,因此永远不会被挤掉。
|
|
530
|
-
const usageLine = formatUsageStats(result.usage, result.model);
|
|
531
|
-
const name = sanitizeProgressLine(result.agent);
|
|
532
|
-
const footer = usageLine ? `\`${name}\` ${usageLine}` : `\`${name}\``;
|
|
533
|
-
const lines = [...logLines];
|
|
534
|
-
if (thinkingChars !== undefined) {
|
|
535
|
-
lines.push(`thinking ( ${thinkingChars} chars )`);
|
|
536
|
-
}
|
|
537
|
-
lines.push(footer);
|
|
538
416
|
onUpdate?.({
|
|
539
|
-
content: [{ type: "text", text:
|
|
417
|
+
content: [{ type: "text", text: progress.render(result.usage, result.model) }],
|
|
540
418
|
details: {},
|
|
541
419
|
});
|
|
542
420
|
};
|
|
@@ -550,24 +428,24 @@ export async function runAgent(
|
|
|
550
428
|
const delta = event.assistantMessageEvent;
|
|
551
429
|
switch (delta.type) {
|
|
552
430
|
case "text_end": {
|
|
553
|
-
|
|
431
|
+
progress.noteTextBlock(delta.content);
|
|
554
432
|
emitUpdate();
|
|
555
433
|
break;
|
|
556
434
|
}
|
|
557
435
|
case "thinking_start": {
|
|
558
436
|
// thinking 不产生日志行,因此不打断工具行合并:跨轮次的连续工具调用
|
|
559
437
|
// 仍累加到同一 `tool:` 行。
|
|
560
|
-
|
|
438
|
+
progress.thinkingStart();
|
|
561
439
|
emitUpdate();
|
|
562
440
|
break;
|
|
563
441
|
}
|
|
564
442
|
case "thinking_delta": {
|
|
565
|
-
|
|
443
|
+
progress.thinkingDelta(delta.delta.length);
|
|
566
444
|
emitUpdate();
|
|
567
445
|
break;
|
|
568
446
|
}
|
|
569
447
|
case "thinking_end": {
|
|
570
|
-
|
|
448
|
+
progress.thinkingEnd();
|
|
571
449
|
emitUpdate();
|
|
572
450
|
break;
|
|
573
451
|
}
|
|
@@ -577,7 +455,8 @@ export async function runAgent(
|
|
|
577
455
|
break;
|
|
578
456
|
}
|
|
579
457
|
case "tool_execution_start": {
|
|
580
|
-
|
|
458
|
+
progress.noteToolCall(event.toolName);
|
|
459
|
+
emitUpdate();
|
|
581
460
|
break;
|
|
582
461
|
}
|
|
583
462
|
case "message_end": {
|