@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/config/settings-schema.d.ts +15 -7
  3. package/dist/types/edit/streaming.d.ts +7 -0
  4. package/dist/types/hashline/hash.d.ts +4 -4
  5. package/dist/types/hashline/recovery.d.ts +5 -0
  6. package/dist/types/lsp/edits.d.ts +8 -1
  7. package/dist/types/session/agent-session.d.ts +16 -0
  8. package/dist/types/session/client-bridge.d.ts +1 -0
  9. package/dist/types/tools/find.d.ts +4 -0
  10. package/dist/types/tools/resolve.d.ts +5 -0
  11. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  12. package/package.json +7 -7
  13. package/src/config/settings-schema.ts +22 -7
  14. package/src/dap/session.ts +58 -5
  15. package/src/edit/modes/patch.ts +46 -0
  16. package/src/edit/streaming.ts +145 -4
  17. package/src/eval/js/context-manager.ts +11 -7
  18. package/src/eval/js/shared/rewrite-imports.ts +21 -9
  19. package/src/eval/js/shared/runtime.ts +2 -1
  20. package/src/hashline/hash.ts +11 -8
  21. package/src/hashline/parser.ts +23 -6
  22. package/src/hashline/recovery.ts +44 -3
  23. package/src/lsp/edits.ts +92 -38
  24. package/src/lsp/index.ts +110 -7
  25. package/src/lsp/utils.ts +13 -0
  26. package/src/modes/acp/acp-client-bridge.ts +1 -0
  27. package/src/modes/components/status-line/segments.ts +1 -1
  28. package/src/modes/components/tool-execution.ts +46 -1
  29. package/src/modes/interactive-mode.ts +33 -7
  30. package/src/prompts/tools/bash.md +14 -0
  31. package/src/prompts/tools/debug.md +4 -1
  32. package/src/prompts/tools/find.md +10 -0
  33. package/src/prompts/tools/hashline.md +5 -3
  34. package/src/prompts/tools/resolve.md +1 -1
  35. package/src/prompts/tools/search.md +2 -1
  36. package/src/prompts/tools/task.md +4 -0
  37. package/src/prompts/tools/todo-write.md +2 -0
  38. package/src/session/agent-session.ts +116 -8
  39. package/src/session/client-bridge.ts +1 -0
  40. package/src/slash-commands/builtin-registry.ts +1 -1
  41. package/src/task/index.ts +33 -5
  42. package/src/task/render.ts +4 -1
  43. package/src/tools/browser/tab-supervisor.ts +23 -3
  44. package/src/tools/browser/tab-worker.ts +4 -2
  45. package/src/tools/browser.ts +1 -1
  46. package/src/tools/debug.ts +19 -2
  47. package/src/tools/find.ts +80 -24
  48. package/src/tools/read.ts +3 -6
  49. package/src/tools/resolve.ts +54 -22
  50. package/src/tools/search.ts +31 -0
  51. package/src/tools/todo-write.ts +11 -4
  52. package/src/tools/tool-timeouts.ts +1 -1
  53. package/src/utils/tools-manager.ts +29 -22
  54. package/src/web/search/providers/codex.ts +3 -0
  55. 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 READY_TIMEOUT_MS = 5_000;
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(options.sessionKey, {
72
- cwd: options.cwd,
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
- await raceWithTimeout(readyPromise, READY_TIMEOUT_MS, "Timed out initializing JS eval worker");
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 !== "ExpressionStatement") return { source: code, returned: false };
307
-
308
- const expression = last as BabelExpressionStatement;
309
- const prefix = code.slice(0, expression.start);
310
- const statement = code.slice(expression.start, expression.end);
311
- const suffix = code.slice(expression.end);
312
- const semicolonMatch = statement.match(/;\s*$/);
313
- const trimmedStatement = semicolonMatch ? statement.slice(0, semicolonMatch.index) : statement;
314
- return { source: `${prefix}__omp_set_final_expr__((${trimmedStatement}));${suffix}`, returned: true };
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
- return await awaitMaybePromise(finalValue);
168
+ const resolved = await awaitMaybePromise(finalValue);
169
+ return resolved;
169
170
  }
170
171
  return awaited;
171
172
  }
@@ -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}. Lines with no letter or digit mix the line number
144
- * into the seed so adjacent identical punctuation-only lines (e.g. brace-only
145
- * lines) get distinct hashes; lines with significant content stay
146
- * line-number-independent so a line is identifiable across small shifts.
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
- const seed = RE_SIGNIFICANT.test(line) ? 0 : idx;
153
- return HL_BIGRAMS[Bun.hash.xxHash32(line, seed) % HL_BIGRAMS_COUNT];
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
  /**
@@ -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(1).trimEnd());
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
- return { payload, nextIndex: index };
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) {
@@ -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 type { HashlineApplyOptions, HashlineEdit } from "./types";
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
- const HASHLINE_RECOVERY_FUZZ_FACTOR = 3;
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
- const recoveryWarnings = [HASHLINE_RECOVERY_WARNING, ...(applied.warnings ?? [])];
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 type { CreateFile, DeleteFile, RenameFile, TextDocumentEdit, TextEdit, WorkspaceEdit } from "./types";
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
- // Handle changes map (legacy format)
67
- if (edit.changes) {
68
- for (const [uri, textEdits] of Object.entries(edit.changes)) {
69
- const filePath = uriToFile(uri);
70
- await applyTextEdits(filePath, textEdits);
71
- applied.push(`Applied ${textEdits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
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
- // Handle documentChanges array (modern format)
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 ("textDocument" in change && change.textDocument && "edits" in change && change.edits) {
79
- // TextDocumentEdit
80
- const docChange = change as TextDocumentEdit;
81
- const filePath = uriToFile(docChange.textDocument.uri);
82
- const textEdits = docChange.edits.filter((e): e is TextEdit => "range" in e && "newText" in e);
83
- await applyTextEdits(filePath, textEdits);
84
- applied.push(`Applied ${textEdits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
85
- } else if ("kind" in change && change.kind) {
86
- // Resource operations
87
- if (change.kind === "create") {
88
- const createOp = change as CreateFile;
89
- const filePath = uriToFile(createOp.uri);
90
- await Bun.write(filePath, "");
91
- applied.push(`Created ${formatPathRelativeToCwd(filePath, cwd)}`);
92
- } else if (change.kind === "rename") {
93
- const renameOp = change as RenameFile;
94
- const oldPath = uriToFile(renameOp.oldUri);
95
- const newPath = uriToFile(renameOp.newUri);
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 { applyTextEditsToString, applyWorkspaceEdit } from "./edits";
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
- signal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
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 applied = await applyWorkspaceEdit(edit, this.session.cwd);
1524
- if (applied.length > 0) {
1525
- summary.push(` ${serverName}:`);
1526
- summary.push(...applied.map(line => ` ${line}`));
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.isFastModeEnabled() && theme.icon.fast) {
87
+ if (ctx.session.isFastModeActive() && theme.icon.fast) {
88
88
  content += ` ${theme.icon.fast}`;
89
89
  }
90
90