@oh-my-pi/pi-coding-agent 15.1.6 → 15.1.7
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 +17 -0
- package/dist/types/config/settings-schema.d.ts +15 -7
- 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/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/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/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
|
|
|
@@ -23,3 +23,17 @@ Executes bash command in shell session for terminal operations like git, bun, ca
|
|
|
23
23
|
- Truncated output is retrievable from `artifact://<id>` (linked in metadata)
|
|
24
24
|
- Exit codes shown on non-zero exit
|
|
25
25
|
</output>
|
|
26
|
+
|
|
27
|
+
{{#if asyncEnabled}}
|
|
28
|
+
# Timeout and async
|
|
29
|
+
|
|
30
|
+
- `timeout` (seconds) caps the **wall-clock duration** of the command. When it elapses the process is killed and the call returns with a timeout annotation. Range: `1`–`3600`s; default `300`s (see `clampTimeout("bash", …)` in `tool-timeouts.ts`).
|
|
31
|
+
- `async: true` only defers **reporting** of the result — it does NOT disable, extend, or detach the timeout. A daemon started with `async: true` is still killed when `timeout` elapses, regardless of how long the agent waits before reading the result.
|
|
32
|
+
- For long-running daemons (dev servers, watchers): either pass an explicit large `timeout` (up to `3600`), or fully detach the process from this shell using `nohup … &` / `setsid … &` / `disown` so it survives independent of the bash call's lifecycle.
|
|
33
|
+
{{/if}}
|
|
34
|
+
|
|
35
|
+
# Output minimizer
|
|
36
|
+
|
|
37
|
+
- Bash stdout/stderr may be rewritten before you see it: long output is head/tail truncated, and test/lint runners (e.g. `bun test`, `cargo test`, ESLint) are passed through heuristic filters that drop noise and keep failures.
|
|
38
|
+
- When the minimizer changes the visible text, the tool appends a `[raw output: artifact://<id>]` footer pointing at the **full untouched capture**. If a run looks suspicious (e.g. only a version banner) or you need the exact bytes, read that artifact.
|
|
39
|
+
- If no footer is present, what you see is what the command actually emitted.
|
|
@@ -4,6 +4,7 @@ Use for launching or attaching debuggers, setting breakpoints, stepping through
|
|
|
4
4
|
<instruction>
|
|
5
5
|
- Prefer over bash for program state, breakpoints, stepping, thread inspection, or interrupting a running process.
|
|
6
6
|
- `action: "launch"` starts a session; `program` is required, `adapter` optional (auto-selected from target path and workspace).
|
|
7
|
+
For Python, set `adapter: "debugpy"` and `program` to the target `.py` file; put interpreter/script flags in `args`.
|
|
7
8
|
- `action: "attach"` connects to an existing process: `pid` for local attach, `port` for remote attach (where the adapter supports it), `adapter` to force a specific debugger.
|
|
8
9
|
- **Breakpoints**: `set_breakpoint`/`remove_breakpoint` with source (`file`+`line`) or function (`function`); optional `condition` for conditional breakpoints.
|
|
9
10
|
- **Flow control**: `continue` (resumes; briefly waits to observe whether the program stops or keeps running), `step_over`/`step_in`/`step_out` (single-step), `pause` (interrupt a running program so you can inspect state).
|
|
@@ -15,6 +16,7 @@ Use for launching or attaching debuggers, setting breakpoints, stepping through
|
|
|
15
16
|
- Only one active debug session is supported at a time.
|
|
16
17
|
- Some adapters require a launched session to receive `configurationDone` before the target actually runs; if the tool says configuration is pending, set breakpoints and then call `continue`.
|
|
17
18
|
- Adapter availability depends on local binaries. Common built-ins: `gdb`, `lldb-dap`, `python -m debugpy.adapter`, `dlv dap`.
|
|
19
|
+
- `program` must be an executable file or debug target, not a directory or interpreter name that resolves to a workspace directory.
|
|
18
20
|
</caution>
|
|
19
21
|
|
|
20
22
|
<examples>
|
|
@@ -24,7 +26,8 @@ Use for launching or attaching debuggers, setting breakpoints, stepping through
|
|
|
24
26
|
3. `debug(action: "continue")`
|
|
25
27
|
4. If the program appears hung: `debug(action: "pause")`
|
|
26
28
|
5. Inspect state with `threads`, `stack_trace`, `scopes`, and `variables`
|
|
27
|
-
|
|
29
|
+
# Launch a Python script with debugpy
|
|
30
|
+
`debug(action: "launch", adapter: "debugpy", program: "scripts/job.py", args: ["--flag"])`
|
|
28
31
|
# Raw debugger command through repl
|
|
29
32
|
`debug(action: "evaluate", expression: "info registers", context: "repl")`
|
|
30
33
|
</examples>
|
|
@@ -2,6 +2,10 @@ Finds files using fast pattern matching that works with any codebase size.
|
|
|
2
2
|
|
|
3
3
|
<instruction>
|
|
4
4
|
- `paths` is required and accepts an array of globs, files, or directories
|
|
5
|
+
- Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`), NEVER as a single comma-joined string (`paths: ["a,b"]` is rejected)
|
|
6
|
+
- `gitignore` defaults to `true` and hides files matched by `.gitignore`. Set `gitignore: false` to find `.env*`, `*.log`, freshly-created build outputs, or anything else your repo ignores
|
|
7
|
+
- `hidden` defaults to `true`; combine with `gitignore: false` to surface dotfiles that are also gitignored
|
|
8
|
+
- `timeout` is in seconds (default 5, clamped to 0.5–60). On timeout, find returns whatever partial matches it has collected with `truncated: true` and a notice — increase `timeout` or narrow the pattern instead of retrying blindly
|
|
5
9
|
- You SHOULD perform multiple searches in parallel when potentially useful
|
|
6
10
|
</instruction>
|
|
7
11
|
|
|
@@ -12,6 +16,12 @@ Matching file paths sorted by modification time (most recent first). Truncated a
|
|
|
12
16
|
<examples>
|
|
13
17
|
# Find files
|
|
14
18
|
`{"paths": ["src/**/*.ts"], "limit": 1000}`
|
|
19
|
+
# Multiple targets — separate array elements
|
|
20
|
+
`{"paths": ["src/**/*.ts", "test/**/*.ts"]}`
|
|
21
|
+
# Find gitignored files like .env
|
|
22
|
+
`{"paths": [".env*"], "gitignore": false}`
|
|
23
|
+
# Long-running search on a slow volume
|
|
24
|
+
`{"paths": ["/Volumes/Storage/**/*.py"], "timeout": 30}`
|
|
15
25
|
</examples>
|
|
16
26
|
|
|
17
27
|
<avoid>
|
|
@@ -19,8 +19,9 @@ Each op line is ONE of:
|
|
|
19
19
|
Op lines carry no content — payload goes on the next line.
|
|
20
20
|
|
|
21
21
|
WRONG: + 5pg| some code
|
|
22
|
+
WRONG: {{hsep}} some code
|
|
22
23
|
RIGHT: + 5pg
|
|
23
|
-
|
|
24
|
+
{{hsep}}some code
|
|
24
25
|
|
|
25
26
|
A single `+`/`<`/`=` op accepts MANY `{{hsep}}` payload lines. To insert N consecutive lines, write ONE op followed by N payload lines — NEVER N ops with one payload each.
|
|
26
27
|
|
|
@@ -37,8 +38,9 @@ RIGHT (one op, many payload lines):
|
|
|
37
38
|
</format-reminder>
|
|
38
39
|
|
|
39
40
|
<rules>
|
|
40
|
-
- Every payload line MUST start with `{{hsep}}`.
|
|
41
|
-
-
|
|
41
|
+
- Every payload line MUST start with `{{hsep}}` immediately followed by payload text. Do NOT add a readability space after `{{hsep}}`.
|
|
42
|
+
- Every character after `{{hsep}}` is file content. If the target line intentionally starts with one space, write exactly one space after `{{hsep}}`; otherwise write none.
|
|
43
|
+
- Payload text is verbatim — NEVER escape unicode.
|
|
42
44
|
- **Payload is only what's NEW relative to your range:**
|
|
43
45
|
- `=` replaces inside; NEVER include lines outside.
|
|
44
46
|
- `+`/`<` adds at the anchor; NEVER repeat line A or neighbors.
|
|
@@ -3,7 +3,7 @@ Resolves a pending action by either applying or discarding it.
|
|
|
3
3
|
- `"apply"` persists / submits the pending action.
|
|
4
4
|
- `"discard"` rejects the pending action.
|
|
5
5
|
- `reason` is required: one short complete sentence explaining why, starting with a capital letter and ending with a period.
|
|
6
|
-
- `extra` (optional) is free-form metadata passed to the resolving tool.
|
|
6
|
+
- `extra` (optional) is free-form metadata passed to the resolving tool. When the pending action is a plan-approval gate, supply `extra.title` (kebab/PascalCase slug for the approved plan filename). For preview-style pending actions (e.g. `ast_edit`), `extra` is unused.
|
|
7
7
|
|
|
8
8
|
Valid whenever a pending action exists — either a preview-style staging (e.g. `ast_edit`) or a long-lived approval gate.
|
|
9
9
|
Call fails with an error when no pending action exists.
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Searches files using powerful regex matching.
|
|
2
2
|
|
|
3
3
|
<instruction>
|
|
4
|
-
- Supports
|
|
4
|
+
- Supports Rust regex syntax (RE2-style — no lookaround or backreferences). Use line anchors or post-filters instead of (?!…)/(?<!…)
|
|
5
5
|
- `paths` is required and accepts an array of files, directories, globs, or internal URLs
|
|
6
|
+
- `paths` is an array; do not embed commas or spaces inside a single entry. Pass `["src", "tests"]` not `["src,tests"]`.
|
|
6
7
|
- Cross-line patterns are detected from literal `\n` or escaped `\\n` in `pattern`
|
|
7
8
|
</instruction>
|
|
8
9
|
|
|
@@ -70,8 +70,12 @@ Parallel when tasks touch disjoint files or are independent refactors/tests.
|
|
|
70
70
|
</assignment-fmt>
|
|
71
71
|
|
|
72
72
|
<agents>
|
|
73
|
+
{{#if spawningDisabled}}
|
|
74
|
+
Agent spawning is disabled for this context.
|
|
75
|
+
{{else}}
|
|
73
76
|
{{#list agents join="\n"}}
|
|
74
77
|
# {{name}}
|
|
75
78
|
{{description}}
|
|
76
79
|
{{/list}}
|
|
80
|
+
{{/if}}
|
|
77
81
|
</agents>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
**Tasks are referenced by their verbatim content string, not by any auto-generated ID. There is no "task-1"/"task-N" identifier — the tool never emits one. Pass the task's content text in the `task` field.**
|
|
2
|
+
|
|
1
3
|
Manages a phased task list. Pass `ops`: a flat array of operations.
|
|
2
4
|
The next pending task is auto-promoted to `in_progress` after each completion.
|
|
3
5
|
Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, and `note`. `pending` is a task status, not an `op`; leave not-yet-started tasks implicit in `init`/`append` lists.
|