@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/types/config/settings-schema.d.ts +15 -7
  3. package/dist/types/hashline/hash.d.ts +4 -4
  4. package/dist/types/hashline/recovery.d.ts +5 -0
  5. package/dist/types/lsp/edits.d.ts +8 -1
  6. package/dist/types/session/agent-session.d.ts +16 -0
  7. package/dist/types/session/client-bridge.d.ts +1 -0
  8. package/dist/types/tools/find.d.ts +4 -0
  9. package/dist/types/tools/resolve.d.ts +5 -0
  10. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  11. package/package.json +7 -7
  12. package/src/config/settings-schema.ts +22 -7
  13. package/src/dap/session.ts +58 -5
  14. package/src/edit/modes/patch.ts +46 -0
  15. package/src/eval/js/context-manager.ts +11 -7
  16. package/src/eval/js/shared/rewrite-imports.ts +21 -9
  17. package/src/eval/js/shared/runtime.ts +2 -1
  18. package/src/hashline/hash.ts +11 -8
  19. package/src/hashline/parser.ts +23 -6
  20. package/src/hashline/recovery.ts +44 -3
  21. package/src/lsp/edits.ts +92 -38
  22. package/src/lsp/index.ts +110 -7
  23. package/src/lsp/utils.ts +13 -0
  24. package/src/modes/acp/acp-client-bridge.ts +1 -0
  25. package/src/modes/components/status-line/segments.ts +1 -1
  26. package/src/prompts/tools/bash.md +14 -0
  27. package/src/prompts/tools/debug.md +4 -1
  28. package/src/prompts/tools/find.md +10 -0
  29. package/src/prompts/tools/hashline.md +5 -3
  30. package/src/prompts/tools/resolve.md +1 -1
  31. package/src/prompts/tools/search.md +2 -1
  32. package/src/prompts/tools/task.md +4 -0
  33. package/src/prompts/tools/todo-write.md +2 -0
  34. package/src/session/agent-session.ts +116 -8
  35. package/src/session/client-bridge.ts +1 -0
  36. package/src/slash-commands/builtin-registry.ts +1 -1
  37. package/src/task/index.ts +33 -5
  38. package/src/task/render.ts +4 -1
  39. package/src/tools/browser/tab-supervisor.ts +23 -3
  40. package/src/tools/browser/tab-worker.ts +4 -2
  41. package/src/tools/browser.ts +1 -1
  42. package/src/tools/debug.ts +19 -2
  43. package/src/tools/find.ts +80 -24
  44. package/src/tools/read.ts +3 -6
  45. package/src/tools/resolve.ts +54 -22
  46. package/src/tools/search.ts +31 -0
  47. package/src/tools/todo-write.ts +11 -4
  48. package/src/tools/tool-timeouts.ts +1 -1
  49. package/src/utils/tools-manager.ts +29 -22
  50. package/src/web/search/providers/codex.ts +3 -0
@@ -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
 
@@ -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
- {{hsep}} some code
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
- - Payload is verbatim NEVER escape unicode.
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. Schema depends on context:
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 full regex syntax (e.g., `log.*Error`, `function\\s+\\w+`); literal braces need escaping (`interface\\{\\}` for `interface{}` in Go)
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.