@oh-my-pi/pi-coding-agent 15.1.6 → 15.1.8
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/CHANGELOG.md +24 -0
- package/dist/types/config/settings-schema.d.ts +15 -7
- package/dist/types/edit/streaming.d.ts +7 -0
- package/dist/types/hashline/hash.d.ts +4 -4
- package/dist/types/hashline/recovery.d.ts +5 -0
- package/dist/types/lsp/edits.d.ts +8 -1
- package/dist/types/session/agent-session.d.ts +16 -0
- package/dist/types/session/client-bridge.d.ts +1 -0
- package/dist/types/tools/find.d.ts +4 -0
- package/dist/types/tools/resolve.d.ts +5 -0
- package/dist/types/tools/tool-timeouts.d.ts +1 -1
- package/package.json +7 -7
- package/src/config/settings-schema.ts +22 -7
- package/src/dap/session.ts +58 -5
- package/src/edit/modes/patch.ts +46 -0
- package/src/edit/streaming.ts +145 -4
- package/src/eval/js/context-manager.ts +11 -7
- package/src/eval/js/shared/rewrite-imports.ts +21 -9
- package/src/eval/js/shared/runtime.ts +2 -1
- package/src/hashline/hash.ts +11 -8
- package/src/hashline/parser.ts +23 -6
- package/src/hashline/recovery.ts +44 -3
- package/src/lsp/edits.ts +92 -38
- package/src/lsp/index.ts +110 -7
- package/src/lsp/utils.ts +13 -0
- package/src/modes/acp/acp-client-bridge.ts +1 -0
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/tool-execution.ts +46 -1
- package/src/modes/interactive-mode.ts +33 -7
- package/src/prompts/tools/bash.md +14 -0
- package/src/prompts/tools/debug.md +4 -1
- package/src/prompts/tools/find.md +10 -0
- package/src/prompts/tools/hashline.md +5 -3
- package/src/prompts/tools/resolve.md +1 -1
- package/src/prompts/tools/search.md +2 -1
- package/src/prompts/tools/task.md +4 -0
- package/src/prompts/tools/todo-write.md +2 -0
- package/src/session/agent-session.ts +116 -8
- package/src/session/client-bridge.ts +1 -0
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/task/index.ts +33 -5
- package/src/task/render.ts +4 -1
- package/src/tools/browser/tab-supervisor.ts +23 -3
- package/src/tools/browser/tab-worker.ts +4 -2
- package/src/tools/browser.ts +1 -1
- package/src/tools/debug.ts +19 -2
- package/src/tools/find.ts +80 -24
- package/src/tools/read.ts +3 -6
- package/src/tools/resolve.ts +54 -22
- package/src/tools/search.ts +31 -0
- package/src/tools/todo-write.ts +11 -4
- package/src/tools/tool-timeouts.ts +1 -1
- package/src/utils/tools-manager.ts +29 -22
- package/src/web/search/providers/codex.ts +3 -0
- package/src/web/search/providers/perplexity.ts +24 -1
|
@@ -52,7 +52,7 @@ interface JsSession {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
const sessions = new Map<string, JsSession>();
|
|
55
|
-
const
|
|
55
|
+
const READY_TIMEOUT_MS_DEFAULT = 5_000;
|
|
56
56
|
|
|
57
57
|
export async function executeInVmContext(options: {
|
|
58
58
|
sessionKey: string;
|
|
@@ -68,10 +68,11 @@ export async function executeInVmContext(options: {
|
|
|
68
68
|
if (options.reset) {
|
|
69
69
|
await resetVmContext(options.sessionKey);
|
|
70
70
|
}
|
|
71
|
-
const session = await acquireSession(
|
|
72
|
-
|
|
73
|
-
sessionId: options.sessionId,
|
|
74
|
-
|
|
71
|
+
const session = await acquireSession(
|
|
72
|
+
options.sessionKey,
|
|
73
|
+
{ cwd: options.cwd, sessionId: options.sessionId },
|
|
74
|
+
options.timeoutMs,
|
|
75
|
+
);
|
|
75
76
|
return await runQueued(session, () => runOnce(session, options));
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -158,7 +159,7 @@ async function runOnce(
|
|
|
158
159
|
}
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
async function acquireSession(sessionKey: string, snapshot: SessionSnapshot): Promise<JsSession> {
|
|
162
|
+
async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, timeoutMs?: number): Promise<JsSession> {
|
|
162
163
|
const existing = sessions.get(sessionKey);
|
|
163
164
|
if (existing && existing.state === "alive") return existing;
|
|
164
165
|
|
|
@@ -186,7 +187,10 @@ async function acquireSession(sessionKey: string, snapshot: SessionSnapshot): Pr
|
|
|
186
187
|
handleSessionMessage(session, msg);
|
|
187
188
|
});
|
|
188
189
|
try {
|
|
189
|
-
|
|
190
|
+
// Cold-start can exceed 5s on slow hosts. Let the caller's per-cell timeout dominate so
|
|
191
|
+
// users can grant more headroom when they raise `timeout` on a cell.
|
|
192
|
+
const readyTimeoutMs = Math.max(READY_TIMEOUT_MS_DEFAULT, timeoutMs ?? 0);
|
|
193
|
+
await raceWithTimeout(readyPromise, readyTimeoutMs, "Timed out initializing JS eval worker");
|
|
190
194
|
} catch (error) {
|
|
191
195
|
unsubscribe();
|
|
192
196
|
await worker.terminate().catch(() => undefined);
|
|
@@ -303,15 +303,27 @@ function returnFinalExpression(code: string): { source: string; returned: boolea
|
|
|
303
303
|
let lastIndex = body.length - 1;
|
|
304
304
|
while (lastIndex >= 0 && body[lastIndex]?.type === "EmptyStatement") lastIndex--;
|
|
305
305
|
const last = lastIndex >= 0 ? body[lastIndex] : undefined;
|
|
306
|
-
if (last?.type
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
306
|
+
if (last?.type === "ExpressionStatement") {
|
|
307
|
+
const expression = last as BabelExpressionStatement;
|
|
308
|
+
const prefix = code.slice(0, expression.start);
|
|
309
|
+
const statement = code.slice(expression.start, expression.end);
|
|
310
|
+
const suffix = code.slice(expression.end);
|
|
311
|
+
const semicolonMatch = statement.match(/;\s*$/);
|
|
312
|
+
const trimmedStatement = semicolonMatch ? statement.slice(0, semicolonMatch.index) : statement;
|
|
313
|
+
return { source: `${prefix}__omp_set_final_expr__((${trimmedStatement}));${suffix}`, returned: true };
|
|
314
|
+
}
|
|
315
|
+
if (last?.type === "ReturnStatement") {
|
|
316
|
+
// Top-level `return value;` is otherwise swallowed: it forces the cell into an async IIFE
|
|
317
|
+
// wrapper that discards the returned value. Rewrite into `__omp_set_final_expr__((expr))`
|
|
318
|
+
// so the runtime can surface the value to the caller just like a trailing expression.
|
|
319
|
+
const ret = last as unknown as { start: number; end: number; argument?: { start: number; end: number } | null };
|
|
320
|
+
if (!ret.argument) return { source: code, returned: false };
|
|
321
|
+
const prefix = code.slice(0, ret.start);
|
|
322
|
+
const suffix = code.slice(ret.end);
|
|
323
|
+
const expr = code.slice(ret.argument.start, ret.argument.end);
|
|
324
|
+
return { source: `${prefix}__omp_set_final_expr__((${expr}));${suffix}`, returned: true };
|
|
325
|
+
}
|
|
326
|
+
return { source: code, returned: false };
|
|
315
327
|
}
|
|
316
328
|
|
|
317
329
|
function isExecutionBoundary(type: string): boolean {
|
|
@@ -165,7 +165,8 @@ export class JsRuntime {
|
|
|
165
165
|
const finalValue = this.#finalExpressionValue;
|
|
166
166
|
this.#finalExpressionSet = false;
|
|
167
167
|
this.#finalExpressionValue = undefined;
|
|
168
|
-
|
|
168
|
+
const resolved = await awaitMaybePromise(finalValue);
|
|
169
|
+
return resolved;
|
|
169
170
|
}
|
|
170
171
|
return awaited;
|
|
171
172
|
}
|
package/src/hashline/hash.ts
CHANGED
|
@@ -136,21 +136,24 @@ export const HL_BODY_SEP = "|";
|
|
|
136
136
|
/** Regex-escaped form of {@link HL_BODY_SEP}, safe for embedding inside a regex. */
|
|
137
137
|
export const HL_BODY_SEP_RE_RAW = regexEscape(HL_BODY_SEP);
|
|
138
138
|
|
|
139
|
-
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
|
|
140
|
-
|
|
141
139
|
/**
|
|
142
140
|
* Compute a 2-character hash of a single line via xxHash32 mod 647 over
|
|
143
|
-
* {@link HL_BIGRAMS}.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
141
|
+
* {@link HL_BIGRAMS}. The hash depends only on the line's content (after
|
|
142
|
+
* stripping CR and trailing whitespace); the `idx` parameter is accepted
|
|
143
|
+
* for call-site symmetry with line numbers but is intentionally unused so
|
|
144
|
+
* that anchors remain stable across line shifts caused by sibling edits.
|
|
147
145
|
*
|
|
148
146
|
* The line input should not include a trailing newline.
|
|
149
147
|
*/
|
|
150
148
|
export function computeLineHash(idx: number, line: string): string {
|
|
149
|
+
void idx;
|
|
151
150
|
line = line.replace(/\r/g, "").trimEnd();
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
// Seed is fixed so the hash depends only on line content. Earlier we mixed
|
|
152
|
+
// in `idx` for blank/punctuation-only lines, but that meant any line shift
|
|
153
|
+
// (e.g. from a sibling edit in the same batch) invalidated anchors whose
|
|
154
|
+
// content had not changed. Identical blank lines are intentionally allowed
|
|
155
|
+
// to collide — the edit op's line number disambiguates them.
|
|
156
|
+
return HL_BIGRAMS[Bun.hash.xxHash32(line, 0) % HL_BIGRAMS_COUNT];
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
/**
|
package/src/hashline/parser.ts
CHANGED
|
@@ -74,19 +74,29 @@ export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
|
|
|
74
74
|
if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
|
|
75
75
|
return cursor;
|
|
76
76
|
}
|
|
77
|
+
/** Returns true when every non-empty payload line starts with `${sep} ` (sep + one space). */
|
|
78
|
+
function hasUniformSeparatorPadding(payload: string[]): boolean {
|
|
79
|
+
let any = false;
|
|
80
|
+
for (const text of payload) {
|
|
81
|
+
if (text.length === 0) continue;
|
|
82
|
+
if (!text.startsWith(" ")) return false;
|
|
83
|
+
any = true;
|
|
84
|
+
}
|
|
85
|
+
return any;
|
|
86
|
+
}
|
|
77
87
|
|
|
78
88
|
function collectPayload(
|
|
79
89
|
lines: string[],
|
|
80
90
|
startIndex: number,
|
|
81
91
|
opLineNum: number,
|
|
82
92
|
requirePayload: boolean,
|
|
83
|
-
): { payload: string[]; nextIndex: number } {
|
|
93
|
+
): { payload: string[]; nextIndex: number; paddingWarning?: string } {
|
|
84
94
|
const payload: string[] = [];
|
|
85
95
|
let index = startIndex;
|
|
86
96
|
while (index < lines.length) {
|
|
87
97
|
const line = lines[index];
|
|
88
98
|
if (line.startsWith(HL_EDIT_SEP)) {
|
|
89
|
-
payload.push(line.slice(
|
|
99
|
+
payload.push(line.slice(HL_EDIT_SEP.length).trimEnd());
|
|
90
100
|
index++;
|
|
91
101
|
continue;
|
|
92
102
|
}
|
|
@@ -115,7 +125,11 @@ function collectPayload(
|
|
|
115
125
|
if (payload.length === 0 && requirePayload) {
|
|
116
126
|
throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);
|
|
117
127
|
}
|
|
118
|
-
|
|
128
|
+
const paddingWarning = hasUniformSeparatorPadding(payload)
|
|
129
|
+
? `line ${opLineNum}: all payload lines start with "${HL_EDIT_SEP} " (separator + space). ` +
|
|
130
|
+
`The space becomes file content. Remove it unless the target file requires leading spaces.`
|
|
131
|
+
: undefined;
|
|
132
|
+
return { payload, nextIndex: index, paddingWarning };
|
|
119
133
|
}
|
|
120
134
|
|
|
121
135
|
export function parseHashline(diff: string): HashlineEdit[] {
|
|
@@ -158,7 +172,8 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
|
|
|
158
172
|
const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
|
|
159
173
|
if (insertBeforeMatch) {
|
|
160
174
|
const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
|
|
161
|
-
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
|
|
175
|
+
const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true);
|
|
176
|
+
if (paddingWarning) warnings.push(paddingWarning);
|
|
162
177
|
for (const text of payload) pushInsert(cursor, text, lineNum);
|
|
163
178
|
i = nextIndex;
|
|
164
179
|
continue;
|
|
@@ -167,7 +182,8 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
|
|
|
167
182
|
const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
|
|
168
183
|
if (insertAfterMatch) {
|
|
169
184
|
const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
|
|
170
|
-
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
|
|
185
|
+
const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true);
|
|
186
|
+
if (paddingWarning) warnings.push(paddingWarning);
|
|
171
187
|
for (const text of payload) pushInsert(cursor, text, lineNum);
|
|
172
188
|
i = nextIndex;
|
|
173
189
|
continue;
|
|
@@ -185,7 +201,8 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
|
|
|
185
201
|
const replaceMatch = REPLACE_OP_RE.exec(line);
|
|
186
202
|
if (replaceMatch) {
|
|
187
203
|
const range = parseRange(replaceMatch[1], lineNum);
|
|
188
|
-
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
|
|
204
|
+
const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, false);
|
|
205
|
+
if (paddingWarning) warnings.push(paddingWarning);
|
|
189
206
|
// `= A..B` with no payload blanks the range to a single empty line.
|
|
190
207
|
const replacement = payload.length === 0 ? [""] : payload;
|
|
191
208
|
for (const text of replacement) {
|
package/src/hashline/recovery.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { generateDiffString } from "../edit/diff";
|
|
|
3
3
|
import type { FileReadCache } from "../edit/file-read-cache";
|
|
4
4
|
import { HashlineMismatchError } from "./anchors";
|
|
5
5
|
import { applyHashlineEdits, type HashlineApplyResult } from "./apply";
|
|
6
|
-
import
|
|
6
|
+
import { computeLineHash } from "./hash";
|
|
7
|
+
import type { Anchor, HashlineApplyOptions, HashlineEdit } from "./types";
|
|
7
8
|
|
|
8
9
|
export interface HashlineRecoveryArgs {
|
|
9
10
|
cache: FileReadCache;
|
|
@@ -19,23 +20,58 @@ export interface HashlineRecoveryResult {
|
|
|
19
20
|
warnings: string[];
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
// Anchors are line-precise; never let Diff.applyPatch slide a hunk onto a
|
|
24
|
+
// duplicate closer 100+ lines away. If the snapshot-based replay does not
|
|
25
|
+
// align by exact line number, refuse and let the model re-read.
|
|
26
|
+
const HASHLINE_RECOVERY_FUZZ_FACTOR = 0;
|
|
23
27
|
|
|
24
28
|
const HASHLINE_RECOVERY_WARNING =
|
|
25
29
|
"Recovered from stale anchors using a previous read snapshot (file changed externally between read and edit).";
|
|
26
30
|
|
|
31
|
+
/** Collect every line anchor an edit batch depends on. */
|
|
32
|
+
function collectEditAnchors(edits: HashlineEdit[]): Anchor[] {
|
|
33
|
+
const anchors: Anchor[] = [];
|
|
34
|
+
for (const edit of edits) {
|
|
35
|
+
if (edit.kind === "delete") {
|
|
36
|
+
anchors.push(edit.anchor);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const cursor = edit.cursor;
|
|
40
|
+
if (cursor.kind === "before_anchor" || cursor.kind === "after_anchor") {
|
|
41
|
+
anchors.push(cursor.anchor);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return anchors;
|
|
45
|
+
}
|
|
46
|
+
|
|
27
47
|
/**
|
|
28
48
|
* Attempt to recover from a `HashlineMismatchError` by replaying the edits
|
|
29
49
|
* against a cached pre-edit snapshot of the file and 3-way-merging the result
|
|
30
50
|
* onto the current on-disk content. Returns `null` when no recovery is
|
|
31
51
|
* possible — callers should propagate the original mismatch error in that
|
|
32
52
|
* case.
|
|
53
|
+
*
|
|
54
|
+
* Recovery is gated on a strict precondition: every line the model anchored
|
|
55
|
+
* MUST be present in the cached snapshot AND its content MUST hash to the
|
|
56
|
+
* model-supplied hash. This prevents 3-way merges from silently sliding onto
|
|
57
|
+
* the wrong site when only tangential parts of the file went stale.
|
|
33
58
|
*/
|
|
34
59
|
export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): HashlineRecoveryResult | null {
|
|
35
60
|
const { cache, absolutePath, currentText, edits, options } = args;
|
|
36
61
|
const snapshot = cache.get(absolutePath);
|
|
37
62
|
if (!snapshot || snapshot.lines.size === 0) return null;
|
|
38
63
|
|
|
64
|
+
// Precondition: the model's anchors must be vouched-for by the cache. If
|
|
65
|
+
// even one anchored line is missing from the snapshot, or its cached
|
|
66
|
+
// content hashes to a different value than the model supplied, refuse —
|
|
67
|
+
// any merge from here is a guess.
|
|
68
|
+
const anchors = collectEditAnchors(edits);
|
|
69
|
+
for (const anchor of anchors) {
|
|
70
|
+
const cachedLine = snapshot.lines.get(anchor.line);
|
|
71
|
+
if (cachedLine === undefined) return null;
|
|
72
|
+
if (computeLineHash(anchor.line, cachedLine) !== anchor.hash) return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
const overlaid = currentText.split("\n");
|
|
40
76
|
let maxCachedLine = 0;
|
|
41
77
|
for (const lineNum of snapshot.lines.keys()) {
|
|
@@ -62,7 +98,12 @@ export function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): Hashlin
|
|
|
62
98
|
if (typeof merged !== "string" || merged === currentText) return null;
|
|
63
99
|
|
|
64
100
|
const mergedDiff = generateDiffString(currentText, merged);
|
|
65
|
-
|
|
101
|
+
// Only surface the recovery warning when the merge actually changed
|
|
102
|
+
// something visible. A no-op merge (e.g. trailing-newline only) is noise.
|
|
103
|
+
const hasNetChange = mergedDiff.firstChangedLine !== undefined;
|
|
104
|
+
const recoveryWarnings = hasNetChange
|
|
105
|
+
? [HASHLINE_RECOVERY_WARNING, ...(applied.warnings ?? [])]
|
|
106
|
+
: [...(applied.warnings ?? [])];
|
|
66
107
|
|
|
67
108
|
return {
|
|
68
109
|
lines: merged,
|
package/src/lsp/edits.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { formatPathRelativeToCwd } from "../tools/path-utils";
|
|
4
|
-
import
|
|
4
|
+
import { ToolError } from "../tools/tool-errors";
|
|
5
|
+
import type {
|
|
6
|
+
CreateFile,
|
|
7
|
+
DeleteFile,
|
|
8
|
+
Position,
|
|
9
|
+
Range,
|
|
10
|
+
RenameFile,
|
|
11
|
+
TextDocumentEdit,
|
|
12
|
+
TextEdit,
|
|
13
|
+
WorkspaceEdit,
|
|
14
|
+
} from "./types";
|
|
5
15
|
import { uriToFile } from "./utils";
|
|
6
16
|
|
|
7
17
|
// =============================================================================
|
|
@@ -23,6 +33,19 @@ export function applyTextEditsToString(content: string, edits: TextEdit[]): stri
|
|
|
23
33
|
return b.range.start.character - a.range.start.character;
|
|
24
34
|
});
|
|
25
35
|
|
|
36
|
+
// Detect overlapping ranges: in reverse-sorted order, each edit's start
|
|
37
|
+
// must be >= the next edit's end. If not, the edits would clobber each other
|
|
38
|
+
// once applied bottom-up (typically a multi-server rename with stale positions).
|
|
39
|
+
for (let i = 0; i < sortedEdits.length - 1; i++) {
|
|
40
|
+
const later = sortedEdits[i].range;
|
|
41
|
+
const earlier = sortedEdits[i + 1].range;
|
|
42
|
+
if (comparePosition(earlier.end, later.start) > 0) {
|
|
43
|
+
throw new ToolError(
|
|
44
|
+
`overlapping LSP edits: ${formatRange(earlier)} conflicts with ${formatRange(later)}; multi-server rename produced inconsistent edits`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
for (const edit of sortedEdits) {
|
|
27
50
|
const { start, end } = edit.range;
|
|
28
51
|
|
|
@@ -42,6 +65,47 @@ export function applyTextEditsToString(content: string, edits: TextEdit[]): stri
|
|
|
42
65
|
return lines.join("\n");
|
|
43
66
|
}
|
|
44
67
|
|
|
68
|
+
function comparePosition(a: Position, b: Position): number {
|
|
69
|
+
return a.line === b.line ? a.character - b.character : a.line - b.line;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatRange(range: Range): string {
|
|
73
|
+
return `${range.start.line + 1}:${range.start.character + 1}-${range.end.line + 1}:${range.end.character + 1}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True when two ranges overlap (share any position other than a touching boundary). */
|
|
77
|
+
export function rangesOverlap(a: Range, b: Range): boolean {
|
|
78
|
+
return comparePosition(a.start, b.end) < 0 && comparePosition(b.start, a.end) < 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Flatten a WorkspaceEdit's text edits into a Map<uri, TextEdit[]>.
|
|
83
|
+
* Resource operations (create/rename/delete) are ignored — callers handle them separately.
|
|
84
|
+
*/
|
|
85
|
+
export function flattenWorkspaceTextEdits(edit: WorkspaceEdit): Map<string, TextEdit[]> {
|
|
86
|
+
const out = new Map<string, TextEdit[]>();
|
|
87
|
+
const push = (uri: string, edits: TextEdit[]) => {
|
|
88
|
+
if (edits.length === 0) return;
|
|
89
|
+
const prev = out.get(uri);
|
|
90
|
+
if (prev) prev.push(...edits);
|
|
91
|
+
else out.set(uri, [...edits]);
|
|
92
|
+
};
|
|
93
|
+
if (edit.changes) {
|
|
94
|
+
const changes = edit.changes;
|
|
95
|
+
for (const uri in changes) push(uri, changes[uri]);
|
|
96
|
+
}
|
|
97
|
+
if (edit.documentChanges) {
|
|
98
|
+
for (const change of edit.documentChanges) {
|
|
99
|
+
if ("textDocument" in change && change.textDocument && "edits" in change && change.edits) {
|
|
100
|
+
const tdc = change as TextDocumentEdit;
|
|
101
|
+
const textEdits = tdc.edits.filter((e): e is TextEdit => "range" in e && "newText" in e);
|
|
102
|
+
push(tdc.textDocument.uri, textEdits);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
45
109
|
/**
|
|
46
110
|
* Apply text edits to a file.
|
|
47
111
|
* Edits are applied in reverse order (bottom-to-top) to preserve line/character indices.
|
|
@@ -63,47 +127,37 @@ export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promi
|
|
|
63
127
|
export async function applyWorkspaceEdit(edit: WorkspaceEdit, cwd: string): Promise<string[]> {
|
|
64
128
|
const applied: string[] = [];
|
|
65
129
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
130
|
+
// Coalesce all text edits per URI before applying so a single file's edits
|
|
131
|
+
// are applied in one pass against a single snapshot — multiple TextDocumentEdits
|
|
132
|
+
// for the same URI would otherwise read stale positions on subsequent writes.
|
|
133
|
+
const textEditsByUri = flattenWorkspaceTextEdits(edit);
|
|
134
|
+
for (const [uri, textEdits] of textEditsByUri) {
|
|
135
|
+
const filePath = uriToFile(uri);
|
|
136
|
+
await applyTextEdits(filePath, textEdits);
|
|
137
|
+
applied.push(`Applied ${textEdits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
|
|
73
138
|
}
|
|
74
139
|
|
|
75
|
-
//
|
|
140
|
+
// Resource operations (create/rename/delete) preserve their original order.
|
|
76
141
|
if (edit.documentChanges) {
|
|
77
142
|
for (const change of edit.documentChanges) {
|
|
78
|
-
if ("
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
const filePath = uriToFile(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
await fs.mkdir(path.dirname(newPath), { recursive: true });
|
|
97
|
-
await fs.rename(oldPath, newPath);
|
|
98
|
-
applied.push(
|
|
99
|
-
`Renamed ${formatPathRelativeToCwd(oldPath, cwd)} → ${formatPathRelativeToCwd(newPath, cwd)}`,
|
|
100
|
-
);
|
|
101
|
-
} else if (change.kind === "delete") {
|
|
102
|
-
const deleteOp = change as DeleteFile;
|
|
103
|
-
const filePath = uriToFile(deleteOp.uri);
|
|
104
|
-
await fs.rm(filePath, { recursive: true });
|
|
105
|
-
applied.push(`Deleted ${formatPathRelativeToCwd(filePath, cwd)}`);
|
|
106
|
-
}
|
|
143
|
+
if (!("kind" in change) || !change.kind) continue;
|
|
144
|
+
if (change.kind === "create") {
|
|
145
|
+
const createOp = change as CreateFile;
|
|
146
|
+
const filePath = uriToFile(createOp.uri);
|
|
147
|
+
await Bun.write(filePath, "");
|
|
148
|
+
applied.push(`Created ${formatPathRelativeToCwd(filePath, cwd)}`);
|
|
149
|
+
} else if (change.kind === "rename") {
|
|
150
|
+
const renameOp = change as RenameFile;
|
|
151
|
+
const oldPath = uriToFile(renameOp.oldUri);
|
|
152
|
+
const newPath = uriToFile(renameOp.newUri);
|
|
153
|
+
await fs.mkdir(path.dirname(newPath), { recursive: true });
|
|
154
|
+
await fs.rename(oldPath, newPath);
|
|
155
|
+
applied.push(`Renamed ${formatPathRelativeToCwd(oldPath, cwd)} → ${formatPathRelativeToCwd(newPath, cwd)}`);
|
|
156
|
+
} else if (change.kind === "delete") {
|
|
157
|
+
const deleteOp = change as DeleteFile;
|
|
158
|
+
const filePath = uriToFile(deleteOp.uri);
|
|
159
|
+
await fs.rm(filePath, { recursive: true });
|
|
160
|
+
applied.push(`Deleted ${formatPathRelativeToCwd(filePath, cwd)}`);
|
|
107
161
|
}
|
|
108
162
|
}
|
|
109
163
|
}
|
package/src/lsp/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { type Theme, theme } from "../modes/theme/theme";
|
|
|
7
7
|
import lspDescription from "../prompts/tools/lsp.md" with { type: "text" };
|
|
8
8
|
import type { ToolSession } from "../tools";
|
|
9
9
|
import { formatPathRelativeToCwd, resolveToCwd } from "../tools/path-utils";
|
|
10
|
-
import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
|
|
10
|
+
import { ToolAbortError, ToolError, throwIfAborted } from "../tools/tool-errors";
|
|
11
11
|
import { clampTimeout } from "../tools/tool-timeouts";
|
|
12
12
|
import {
|
|
13
13
|
ensureFileOpen,
|
|
@@ -25,7 +25,13 @@ import {
|
|
|
25
25
|
} from "./client";
|
|
26
26
|
import { getLinterClient } from "./clients";
|
|
27
27
|
import { getServersForFile, type LspConfig, loadConfig } from "./config";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
applyTextEdits,
|
|
30
|
+
applyTextEditsToString,
|
|
31
|
+
applyWorkspaceEdit,
|
|
32
|
+
flattenWorkspaceTextEdits,
|
|
33
|
+
rangesOverlap,
|
|
34
|
+
} from "./edits";
|
|
29
35
|
import { detectLspmux } from "./lspmux";
|
|
30
36
|
import { renderCall, renderResult } from "./render";
|
|
31
37
|
import {
|
|
@@ -1197,7 +1203,8 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
1197
1203
|
const { action, file, line, symbol, query, new_name, apply, timeout } = params;
|
|
1198
1204
|
const timeoutSec = clampTimeout("lsp", timeout);
|
|
1199
1205
|
const timeoutSignal = AbortSignal.timeout(timeoutSec * 1000);
|
|
1200
|
-
|
|
1206
|
+
const callerSignal = signal;
|
|
1207
|
+
signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
|
|
1201
1208
|
throwIfAborted(signal);
|
|
1202
1209
|
|
|
1203
1210
|
const config = getConfig(this.session.cwd);
|
|
@@ -1519,11 +1526,81 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
1519
1526
|
}
|
|
1520
1527
|
|
|
1521
1528
|
const summary: string[] = [];
|
|
1529
|
+
|
|
1530
|
+
// Coalesce per-URI edits across servers before applying. Each server
|
|
1531
|
+
// computed positions against the pre-edit file content, so applying
|
|
1532
|
+
// server A then re-reading for server B yields stale positions and
|
|
1533
|
+
// produces malformed imports. Group all text edits by URI, prefer the
|
|
1534
|
+
// project-primary (project-aware) server on overlap, and apply once
|
|
1535
|
+
// per URI from a single snapshot.
|
|
1536
|
+
const serverConfigByName = new Map(servers);
|
|
1537
|
+
interface AcceptedBucket {
|
|
1538
|
+
primaryServer: string;
|
|
1539
|
+
edits: TextEdit[];
|
|
1540
|
+
discarded: number;
|
|
1541
|
+
conflictServers: Set<string>;
|
|
1542
|
+
}
|
|
1543
|
+
const acceptedByUri = new Map<string, AcceptedBucket>();
|
|
1522
1544
|
for (const { serverName, edit } of perServerEdits) {
|
|
1523
|
-
const
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1545
|
+
const cfg = serverConfigByName.get(serverName);
|
|
1546
|
+
const incomingPrimary = cfg ? isProjectAwareLspServer(cfg) : false;
|
|
1547
|
+
const flat = flattenWorkspaceTextEdits(edit);
|
|
1548
|
+
for (const [uri, edits] of flat) {
|
|
1549
|
+
const existing = acceptedByUri.get(uri);
|
|
1550
|
+
if (!existing) {
|
|
1551
|
+
acceptedByUri.set(uri, {
|
|
1552
|
+
primaryServer: serverName,
|
|
1553
|
+
edits: [...edits],
|
|
1554
|
+
discarded: 0,
|
|
1555
|
+
conflictServers: new Set(),
|
|
1556
|
+
});
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
const existingCfg = serverConfigByName.get(existing.primaryServer);
|
|
1560
|
+
const existingIsPrimary = existingCfg ? isProjectAwareLspServer(existingCfg) : false;
|
|
1561
|
+
if (incomingPrimary && !existingIsPrimary) {
|
|
1562
|
+
// Promote incoming to primary; keep existing edits that don't overlap.
|
|
1563
|
+
const keptOld: TextEdit[] = [];
|
|
1564
|
+
let discardedOld = 0;
|
|
1565
|
+
for (const oe of existing.edits) {
|
|
1566
|
+
if (edits.some(ne => rangesOverlap(ne.range, oe.range))) discardedOld++;
|
|
1567
|
+
else keptOld.push(oe);
|
|
1568
|
+
}
|
|
1569
|
+
if (discardedOld > 0) existing.conflictServers.add(existing.primaryServer);
|
|
1570
|
+
existing.discarded += discardedOld;
|
|
1571
|
+
existing.primaryServer = serverName;
|
|
1572
|
+
existing.edits = [...edits, ...keptOld];
|
|
1573
|
+
} else {
|
|
1574
|
+
// Existing wins; discard incoming edits that overlap any accepted edit.
|
|
1575
|
+
let discardedNew = 0;
|
|
1576
|
+
for (const ne of edits) {
|
|
1577
|
+
if (existing.edits.some(ae => rangesOverlap(ae.range, ne.range))) {
|
|
1578
|
+
discardedNew++;
|
|
1579
|
+
} else {
|
|
1580
|
+
existing.edits.push(ne);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
if (discardedNew > 0) {
|
|
1584
|
+
existing.conflictServers.add(serverName);
|
|
1585
|
+
existing.discarded += discardedNew;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
for (const [uri, bucket] of acceptedByUri) {
|
|
1592
|
+
const filePath = uriToFile(uri);
|
|
1593
|
+
await applyTextEdits(filePath, bucket.edits);
|
|
1594
|
+
const rel = formatPathRelativeToCwd(filePath, this.session.cwd);
|
|
1595
|
+
summary.push(` ${bucket.primaryServer}: applied ${bucket.edits.length} edit(s) to ${rel}`);
|
|
1596
|
+
if (bucket.discarded > 0) {
|
|
1597
|
+
const others = Array.from(bucket.conflictServers).join(", ");
|
|
1598
|
+
summary.push(
|
|
1599
|
+
` note: discarded ${bucket.discarded} overlapping edit(s) from ${others} (kept ${bucket.primaryServer})`,
|
|
1600
|
+
);
|
|
1601
|
+
logger.warn(
|
|
1602
|
+
`lsp rename_file: discarded ${bucket.discarded} overlapping edit(s) from ${others} on ${rel}; kept ${bucket.primaryServer}`,
|
|
1603
|
+
);
|
|
1527
1604
|
}
|
|
1528
1605
|
}
|
|
1529
1606
|
|
|
@@ -1844,6 +1921,22 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
1844
1921
|
await ensureFileOpen(client, targetFile, signal);
|
|
1845
1922
|
}
|
|
1846
1923
|
|
|
1924
|
+
// For project-aware servers, references/rename/definition without a `symbol`
|
|
1925
|
+
// silently falls back to the first non-whitespace column on the line, which
|
|
1926
|
+
// frequently points at the wrong identifier (decorator, keyword, parameter)
|
|
1927
|
+
// and the server returns plausible-looking but unrelated results. Require
|
|
1928
|
+
// `symbol` explicitly so callers cannot accidentally trigger that fallback.
|
|
1929
|
+
if (
|
|
1930
|
+
targetFile &&
|
|
1931
|
+
line !== undefined &&
|
|
1932
|
+
!symbol &&
|
|
1933
|
+
(action === "references" || action === "rename" || action === "definition") &&
|
|
1934
|
+
isProjectAwareLspServer(serverConfig)
|
|
1935
|
+
) {
|
|
1936
|
+
throw new ToolError(
|
|
1937
|
+
`symbol is required for project-aware ${action}; pass symbol=<name>, optionally symbol#N for repeated occurrences`,
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1847
1940
|
const uri = targetFile ? fileToUri(targetFile) : "";
|
|
1848
1941
|
const resolvedLine = line ?? 1;
|
|
1849
1942
|
const resolvedCharacter = targetFile ? await resolveSymbolColumn(targetFile, resolvedLine, symbol) : 0;
|
|
@@ -2166,7 +2259,17 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
2166
2259
|
details: { serverName, action, success: true, request: params },
|
|
2167
2260
|
};
|
|
2168
2261
|
} catch (err) {
|
|
2262
|
+
if (err instanceof ToolError) throw err;
|
|
2169
2263
|
if (err instanceof ToolAbortError || signal?.aborted) {
|
|
2264
|
+
// Distinguish a wall-clock timeout from a caller cancel:
|
|
2265
|
+
// callerSignal aborting → real cancel (re-throw ToolAbortError);
|
|
2266
|
+
// timeoutSignal aborting without callerSignal → emit a ToolError naming the
|
|
2267
|
+
// elapsed budget and server, instead of opaque "Operation aborted".
|
|
2268
|
+
if (timeoutSignal.aborted && !callerSignal?.aborted) {
|
|
2269
|
+
throw new ToolError(
|
|
2270
|
+
`LSP ${action} timed out after ${timeoutSec}s on ${serverName}. The server may still be indexing; try again or pass timeout=<larger>.`,
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2170
2273
|
throw new ToolAbortError();
|
|
2171
2274
|
}
|
|
2172
2275
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
package/src/lsp/utils.ts
CHANGED
|
@@ -581,15 +581,28 @@ function firstNonWhitespaceColumn(lineText: string): number {
|
|
|
581
581
|
return match ? (match.index ?? 0) : 0;
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
+
const BARE_IDENTIFIER_RE = /^[A-Za-z_][\w]*$/;
|
|
585
|
+
const IDENTIFIER_CHAR_RE = /[A-Za-z0-9_$]/;
|
|
586
|
+
|
|
584
587
|
function findSymbolMatchIndexes(lineText: string, symbol: string, caseInsensitive = false): number[] {
|
|
585
588
|
if (symbol.length === 0) return [];
|
|
586
589
|
const haystack = caseInsensitive ? lineText.toLowerCase() : lineText;
|
|
587
590
|
const needle = caseInsensitive ? symbol.toLowerCase() : symbol;
|
|
591
|
+
const requireWordBoundary = BARE_IDENTIFIER_RE.test(symbol);
|
|
588
592
|
const indexes: number[] = [];
|
|
589
593
|
let fromIndex = 0;
|
|
590
594
|
while (fromIndex <= haystack.length - needle.length) {
|
|
591
595
|
const matchIndex = haystack.indexOf(needle, fromIndex);
|
|
592
596
|
if (matchIndex === -1) break;
|
|
597
|
+
if (requireWordBoundary) {
|
|
598
|
+
const before = matchIndex > 0 ? haystack[matchIndex - 1] : "";
|
|
599
|
+
const afterIdx = matchIndex + needle.length;
|
|
600
|
+
const after = afterIdx < haystack.length ? haystack[afterIdx] : "";
|
|
601
|
+
if (IDENTIFIER_CHAR_RE.test(before) || IDENTIFIER_CHAR_RE.test(after)) {
|
|
602
|
+
fromIndex = matchIndex + 1;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
593
606
|
indexes.push(matchIndex);
|
|
594
607
|
fromIndex = matchIndex + needle.length;
|
|
595
608
|
}
|
|
@@ -124,6 +124,7 @@ async function requestPermission(
|
|
|
124
124
|
...(toolCall.kind ? { kind: toolCall.kind as ToolCallUpdate["kind"] } : {}),
|
|
125
125
|
...(toolCall.status ? { status: toolCall.status as ToolCallUpdate["status"] } : {}),
|
|
126
126
|
...(toolCall.rawInput !== undefined ? { rawInput: toolCall.rawInput } : {}),
|
|
127
|
+
...(toolCall.content ? { content: toolCall.content as ToolCallUpdate["content"] } : {}),
|
|
127
128
|
...(toolCall.locations ? { locations: toolCall.locations } : {}),
|
|
128
129
|
};
|
|
129
130
|
const acpOptions: AcpPermissionOption[] = options.map(option => ({
|
|
@@ -84,7 +84,7 @@ const modelSegment: StatusLineSegment = {
|
|
|
84
84
|
|
|
85
85
|
let content = withIcon(theme.icon.model, modelName);
|
|
86
86
|
|
|
87
|
-
if (ctx.session.
|
|
87
|
+
if (ctx.session.isFastModeActive() && theme.icon.fast) {
|
|
88
88
|
content += ` ${theme.icon.fast}`;
|
|
89
89
|
}
|
|
90
90
|
|