@oh-my-pi/pi-coding-agent 14.5.8 → 14.5.10

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 (58) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/package.json +7 -15
  3. package/scripts/build-binary.ts +1 -1
  4. package/src/cli/update-cli.ts +25 -1
  5. package/src/config/model-registry.ts +21 -19
  6. package/src/config/settings-schema.ts +14 -19
  7. package/src/discovery/claude-plugins.ts +28 -3
  8. package/src/edit/modes/atom.lark +7 -5
  9. package/src/edit/modes/atom.ts +510 -73
  10. package/src/edit/modes/hashline.ts +172 -91
  11. package/src/extensibility/extensions/runner.ts +34 -1
  12. package/src/extensibility/extensions/types.ts +8 -0
  13. package/src/lsp/client.ts +27 -35
  14. package/src/lsp/index.ts +2 -4
  15. package/src/lsp/render.ts +0 -3
  16. package/src/lsp/types.ts +1 -4
  17. package/src/lsp/utils.ts +18 -14
  18. package/src/memories/index.ts +5 -0
  19. package/src/modes/components/settings-defs.ts +1 -1
  20. package/src/modes/controllers/command-controller.ts +17 -0
  21. package/src/modes/controllers/input-controller.ts +7 -1
  22. package/src/modes/controllers/selector-controller.ts +2 -2
  23. package/src/modes/interactive-mode.ts +57 -26
  24. package/src/modes/theme/theme.ts +10 -1
  25. package/src/modes/types.ts +5 -3
  26. package/src/modes/utils/context-usage.ts +294 -0
  27. package/src/modes/utils/ui-helpers.ts +19 -6
  28. package/src/prompts/system/auto-continue.md +1 -0
  29. package/src/prompts/tools/atom.md +99 -44
  30. package/src/prompts/tools/exit-plan-mode.md +5 -39
  31. package/src/prompts/tools/github.md +3 -3
  32. package/src/prompts/tools/lsp.md +2 -3
  33. package/src/prompts/tools/{run-command.md → recipe.md} +1 -1
  34. package/src/prompts/tools/task.md +34 -147
  35. package/src/prompts/tools/todo-write.md +22 -64
  36. package/src/sdk.ts +13 -2
  37. package/src/session/agent-session.ts +175 -79
  38. package/src/session/compaction/compaction.ts +35 -22
  39. package/src/session/session-dump-format.ts +1 -0
  40. package/src/session/session-manager.ts +19 -2
  41. package/src/slash-commands/builtin-registry.ts +12 -5
  42. package/src/tools/bash.ts +9 -4
  43. package/src/tools/debug.ts +57 -70
  44. package/src/tools/gh.ts +267 -119
  45. package/src/tools/index.ts +7 -7
  46. package/src/tools/{run-command → recipe}/index.ts +19 -19
  47. package/src/tools/recipe/render.ts +19 -0
  48. package/src/tools/{run-command → recipe}/runner.ts +28 -7
  49. package/src/tools/{run-command → recipe}/runners/pkg.ts +23 -53
  50. package/src/tools/renderers.ts +2 -2
  51. package/src/utils/git.ts +61 -2
  52. package/src/web/search/providers/searxng.ts +71 -13
  53. package/src/tools/run-command/render.ts +0 -18
  54. /package/src/tools/{run-command → recipe}/runners/cargo.ts +0 -0
  55. /package/src/tools/{run-command → recipe}/runners/index.ts +0 -0
  56. /package/src/tools/{run-command → recipe}/runners/just.ts +0 -0
  57. /package/src/tools/{run-command → recipe}/runners/make.ts +0 -0
  58. /package/src/tools/{run-command → recipe}/runners/task.ts +0 -0
@@ -53,7 +53,7 @@ export type HashlineEdit =
53
53
  // Accept both `|` (canonical) and `:` (legacy) so re-reads of older outputs still parse.
54
54
  const HASHLINE_CONTENT_SEPARATOR_RE = "[:|]";
55
55
  const HASHLINE_PREFIX_RE = new RegExp(
56
- `^\\s*(?:>>>|>>)?\\s*(?:\\+\\s*)?\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
56
+ `^\\s*(?:>>>|>>)?\\s*(?:[+*]\\s*)?\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
57
57
  );
58
58
  const HASHLINE_PREFIX_PLUS_RE = new RegExp(
59
59
  `^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
@@ -503,7 +503,7 @@ export function parseTag(ref: string): { line: number; hash: string } {
503
503
  // 1. optional leading ">+-" markers and whitespace
504
504
  // 2. line number (1+ digits)
505
505
  // 3. hash (one BPE bigram from HASHLINE_BIGRAMS) directly adjacent (no separator)
506
- const match = ref.match(new RegExp(`^\\s*[>+-]*\\s*(\\d+)(${HASHLINE_BIGRAM_RE_SRC})`));
506
+ const match = ref.match(new RegExp(`^\\s*[>+\\-*]*\\s*(\\d+)(${HASHLINE_BIGRAM_RE_SRC})`));
507
507
  if (!match) {
508
508
  throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
509
509
  }
@@ -605,7 +605,6 @@ export class HashlineMismatchError extends Error {
605
605
  `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read (marked *).`,
606
606
  "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
607
607
  );
608
- lines.push("");
609
608
 
610
609
  let prevLine = -1;
611
610
  for (const lineNum of sorted) {
@@ -650,7 +649,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
650
649
  /**
651
650
  * Default search window for {@link tryRebaseAnchor} (lines on each side of the requested anchor).
652
651
  */
653
- export const ANCHOR_REBASE_WINDOW = 2;
652
+ export const ANCHOR_REBASE_WINDOW = 5;
654
653
 
655
654
  /**
656
655
  * Look for the requested hash within ±`window` lines of `anchor.line`.
@@ -1008,23 +1007,40 @@ export interface CompactHashlineDiffPreview {
1008
1007
  }
1009
1008
 
1010
1009
  export interface CompactHashlineDiffOptions {
1010
+ /** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */
1011
1011
  maxUnchangedRun?: number;
1012
- maxDeletionRun?: number;
1013
- maxOutputLines?: number;
1014
1012
  }
1015
1013
 
1016
1014
  const NUMBERED_DIFF_LINE_RE = /^([ +-])(\s*\d+)\|(.*)$/;
1017
1015
  const HASHLINE_PREVIEW_PLACEHOLDER = " ";
1016
+ const ELLIPSIS = "...";
1018
1017
 
1019
- type DiffRunKind = " " | "+" | "-" | "meta";
1020
- type DiffRun = { kind: DiffRunKind; lines: string[] };
1018
+ type DiffEntryKind = " " | "+" | "-" | "*";
1019
+ type RunKind = DiffEntryKind | "meta";
1020
+
1021
+ interface DiffEntry {
1022
+ kind: DiffEntryKind;
1023
+ oldLine: number;
1024
+ newLine: number;
1025
+ content: string;
1026
+ }
1027
+
1028
+ interface MetaEntry {
1029
+ kind: "meta";
1030
+ raw: string;
1031
+ }
1032
+
1033
+ type Entry = DiffEntry | MetaEntry;
1034
+
1035
+ interface Run {
1036
+ kind: RunKind;
1037
+ entries: Entry[];
1038
+ }
1021
1039
 
1022
1040
  interface ParsedNumberedDiffLine {
1023
1041
  kind: " " | "+" | "-";
1024
1042
  lineNumber: number;
1025
- lineWidth: number;
1026
1043
  content: string;
1027
- raw: string;
1028
1044
  }
1029
1045
 
1030
1046
  interface CompactPreviewCounters {
@@ -1039,11 +1055,10 @@ function parseNumberedDiffLine(line: string): ParsedNumberedDiffLine | undefined
1039
1055
  const kind = match[1];
1040
1056
  if (kind !== " " && kind !== "+" && kind !== "-") return undefined;
1041
1057
 
1042
- const lineField = match[2];
1043
- const lineNumber = Number(lineField.trim());
1058
+ const lineNumber = Number(match[2].trim());
1044
1059
  if (!Number.isInteger(lineNumber)) return undefined;
1045
1060
 
1046
- return { kind, lineNumber, lineWidth: lineField.length, content: match[3], raw: line };
1061
+ return { kind, lineNumber, content: match[3] };
1047
1062
  }
1048
1063
 
1049
1064
  function syncOldLineCounters(counters: CompactPreviewCounters, lineNumber: number): void {
@@ -1070,105 +1085,169 @@ function syncNewLineCounters(counters: CompactPreviewCounters, lineNumber: numbe
1070
1085
  counters.newLine = lineNumber;
1071
1086
  }
1072
1087
 
1073
- function formatCompactHashlineLine(kind: " " | "+", lineNumber: number, content: string): string {
1074
- return `${kind}${lineNumber}${computeLineHash(lineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
1075
- }
1088
+ /**
1089
+ * Parse a unified-diff-with-line-numbers blob into structured entries while
1090
+ * tracking both old- and new-file line numbers. `...` markers (emitted by
1091
+ * {@link generateDiffString} for collapsed context) sync counters but are
1092
+ * preserved as passthrough entries so the original ellipsis remains visible.
1093
+ */
1094
+ function parseDiffEntries(lines: string[]): Entry[] {
1095
+ const entries: Entry[] = [];
1096
+ const counters: CompactPreviewCounters = {};
1076
1097
 
1077
- function formatCompactRemovedLine(lineNumber: number, content: string): string {
1078
- return `-${lineNumber}${HASHLINE_PREVIEW_PLACEHOLDER}${HASHLINE_CONTENT_SEPARATOR}${content}`;
1079
- }
1098
+ for (const line of lines) {
1099
+ const parsed = parseNumberedDiffLine(line);
1100
+ if (!parsed) {
1101
+ entries.push({ kind: "meta", raw: line });
1102
+ continue;
1103
+ }
1080
1104
 
1081
- function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters): { kind: DiffRunKind; text: string } {
1082
- const parsed = parseNumberedDiffLine(line);
1083
- if (!parsed) return { kind: "meta", text: line };
1105
+ const isEllipsis = parsed.content === ELLIPSIS;
1084
1106
 
1085
- if (parsed.content === "...") {
1086
1107
  if (parsed.kind === "+") {
1087
1108
  syncNewLineCounters(counters, parsed.lineNumber);
1088
- } else {
1089
- syncOldLineCounters(counters, parsed.lineNumber);
1109
+ const newLine = counters.newLine ?? parsed.lineNumber;
1110
+ const oldLine = counters.oldLine ?? parsed.lineNumber;
1111
+ entries.push({ kind: "+", oldLine, newLine, content: parsed.content });
1112
+ if (!isEllipsis) counters.newLine = newLine + 1;
1113
+ continue;
1090
1114
  }
1091
- return { kind: parsed.kind, text: parsed.raw };
1092
- }
1093
1115
 
1094
- switch (parsed.kind) {
1095
- case "+": {
1096
- syncNewLineCounters(counters, parsed.lineNumber);
1097
- const newLine = counters.newLine;
1098
- if (newLine === undefined) return { kind: "+", text: parsed.raw };
1099
- const text = formatCompactHashlineLine("+", newLine, parsed.content);
1100
- counters.newLine = newLine + 1;
1101
- return { kind: "+", text };
1102
- }
1103
- case "-": {
1116
+ if (parsed.kind === "-") {
1104
1117
  syncOldLineCounters(counters, parsed.lineNumber);
1105
- const text = formatCompactRemovedLine(parsed.lineNumber, parsed.content);
1106
- counters.oldLine = parsed.lineNumber + 1;
1107
- return { kind: "-", text };
1118
+ const oldLine = parsed.lineNumber;
1119
+ const newLine = counters.newLine ?? parsed.lineNumber;
1120
+ entries.push({ kind: "-", oldLine, newLine, content: parsed.content });
1121
+ if (!isEllipsis) counters.oldLine = oldLine + 1;
1122
+ continue;
1108
1123
  }
1109
- case " ": {
1110
- syncOldLineCounters(counters, parsed.lineNumber);
1111
- const newLine = counters.newLine;
1112
- if (newLine === undefined) return { kind: " ", text: parsed.raw };
1113
- const text = formatCompactHashlineLine(" ", newLine, parsed.content);
1114
- counters.oldLine = parsed.lineNumber + 1;
1124
+
1125
+ // Context line.
1126
+ syncOldLineCounters(counters, parsed.lineNumber);
1127
+ const oldLine = parsed.lineNumber;
1128
+ const newLine = counters.newLine ?? parsed.lineNumber;
1129
+ entries.push({ kind: " ", oldLine, newLine, content: parsed.content });
1130
+ if (!isEllipsis) {
1131
+ counters.oldLine = oldLine + 1;
1115
1132
  counters.newLine = newLine + 1;
1116
- return { kind: " ", text };
1117
1133
  }
1118
1134
  }
1119
- }
1120
1135
 
1121
- function splitDiffRuns(lines: string[]): DiffRun[] {
1122
- const runs: DiffRun[] = [];
1123
- const counters: CompactPreviewCounters = {};
1136
+ return entries;
1137
+ }
1124
1138
 
1125
- for (const line of lines) {
1126
- const formatted = formatCompactPreviewLine(line, counters);
1139
+ function groupRuns(entries: Entry[]): Run[] {
1140
+ const runs: Run[] = [];
1141
+ for (const entry of entries) {
1127
1142
  const prev = runs[runs.length - 1];
1128
- if (prev && prev.kind === formatted.kind) {
1129
- prev.lines.push(formatted.text);
1143
+ if (prev && prev.kind === entry.kind) {
1144
+ prev.entries.push(entry);
1130
1145
  continue;
1131
1146
  }
1132
- runs.push({ kind: formatted.kind, lines: [formatted.text] });
1147
+ runs.push({ kind: entry.kind, entries: [entry] });
1133
1148
  }
1134
-
1135
1149
  return runs;
1136
1150
  }
1137
1151
 
1138
- function collapseFromStart(lines: string[], maxLines: number, label: string): string[] {
1139
- if (lines.length <= maxLines) return lines;
1140
- const hidden = lines.length - maxLines;
1141
- return [...lines.slice(0, maxLines), ` ... ${hidden} more ${label} lines`];
1152
+ /**
1153
+ * Collapse adjacent `(-, +)` runs into a single `*` run for paired
1154
+ * modifications. The i-th removed line pairs with the i-th added line — in
1155
+ * unified-diff convention they replaced each other in place — and is shown as
1156
+ * `*<newLine><hash>|<newContent>` instead of two lines `-<old>` + `+<new>`.
1157
+ * Surplus removals or additions remain as their own runs after the paired
1158
+ * block, preserving the unified-diff `del-then-add` ordering.
1159
+ */
1160
+ function pairModifications(runs: Run[]): Run[] {
1161
+ const isPairable = (entry: Entry): entry is DiffEntry => entry.kind !== "meta" && entry.content !== ELLIPSIS;
1162
+
1163
+ const out: Run[] = [];
1164
+ for (let i = 0; i < runs.length; i++) {
1165
+ const run = runs[i];
1166
+ const next = runs[i + 1];
1167
+ if (run.kind !== "-" || !next || next.kind !== "+") {
1168
+ out.push(run);
1169
+ continue;
1170
+ }
1171
+
1172
+ const dels = run.entries.filter(isPairable);
1173
+ const adds = next.entries.filter(isPairable);
1174
+ const pairCount = Math.min(dels.length, adds.length);
1175
+ if (pairCount === 0) {
1176
+ out.push(run);
1177
+ continue;
1178
+ }
1179
+
1180
+ const mods: Entry[] = [];
1181
+ for (let p = 0; p < pairCount; p++) {
1182
+ mods.push({
1183
+ kind: "*",
1184
+ oldLine: dels[p].oldLine,
1185
+ newLine: adds[p].newLine,
1186
+ content: adds[p].content,
1187
+ });
1188
+ }
1189
+ out.push({ kind: "*", entries: mods });
1190
+
1191
+ if (dels.length > pairCount) {
1192
+ out.push({ kind: "-", entries: dels.slice(pairCount) });
1193
+ }
1194
+ if (adds.length > pairCount) {
1195
+ out.push({ kind: "+", entries: adds.slice(pairCount) });
1196
+ }
1197
+
1198
+ i++; // consume the `+` run
1199
+ }
1200
+ return out;
1142
1201
  }
1143
1202
 
1144
- function _collapseFromEnd(lines: string[], maxLines: number, label: string): string[] {
1145
- if (lines.length <= maxLines) return lines;
1146
- const hidden = lines.length - maxLines;
1147
- return [` ... ${hidden} more ${label} lines`, ...lines.slice(-maxLines)];
1203
+ function formatEntry(entry: Entry): string {
1204
+ if (entry.kind === "meta") return entry.raw;
1205
+
1206
+ if (entry.content === ELLIPSIS) {
1207
+ // Preserve the `... <line>|...` ellipsis marker emitted by generateDiffString.
1208
+ const lineNum = entry.kind === "+" || entry.kind === "*" ? entry.newLine : entry.oldLine;
1209
+ const prefix = entry.kind === "*" ? "+" : entry.kind;
1210
+ return `${prefix}${lineNum}${HASHLINE_PREVIEW_PLACEHOLDER}${HASHLINE_CONTENT_SEPARATOR}${ELLIPSIS}`;
1211
+ }
1212
+
1213
+ switch (entry.kind) {
1214
+ case "+":
1215
+ return `+${entry.newLine}${computeLineHash(entry.newLine, entry.content)}${HASHLINE_CONTENT_SEPARATOR}${entry.content}`;
1216
+ case "-":
1217
+ return `-${entry.oldLine}${HASHLINE_PREVIEW_PLACEHOLDER}${HASHLINE_CONTENT_SEPARATOR}${entry.content}`;
1218
+ case " ":
1219
+ return ` ${entry.newLine}${computeLineHash(entry.newLine, entry.content)}${HASHLINE_CONTENT_SEPARATOR}${entry.content}`;
1220
+ case "*":
1221
+ return `*${entry.newLine}${computeLineHash(entry.newLine, entry.content)}${HASHLINE_CONTENT_SEPARATOR}${entry.content}`;
1222
+ }
1148
1223
  }
1149
1224
 
1150
- function collapseFromMiddle(lines: string[], maxLines: number, label: string): string[] {
1151
- if (lines.length <= maxLines * 2) return lines;
1152
- const hidden = lines.length - maxLines * 2;
1153
- return [...lines.slice(0, maxLines), ` ... ${hidden} more ${label} lines`, ...lines.slice(-maxLines)];
1225
+ function collapseUnchangedMiddle(entries: Entry[], maxRun: number): string[] {
1226
+ if (entries.length <= maxRun * 2) return entries.map(formatEntry);
1227
+ const hidden = entries.length - maxRun * 2;
1228
+ return [
1229
+ ...entries.slice(0, maxRun).map(formatEntry),
1230
+ ` ... ${hidden} more unchanged lines`,
1231
+ ...entries.slice(-maxRun).map(formatEntry),
1232
+ ];
1154
1233
  }
1155
1234
 
1156
1235
  /**
1157
1236
  * Build a compact diff preview suitable for model-visible tool responses.
1158
1237
  *
1159
- * Collapses long unchanged runs and long consecutive additions/removals so the
1160
- * model sees the shape of edits without replaying full file content.
1238
+ * Every changed line added, removed, or modified is shown in full. Only
1239
+ * unchanged context blocks between or around changes get truncated. Adjacent
1240
+ * `-`/`+` pairs are folded into single `*` modification lines so the common
1241
+ * 1:1 line-replacement case stays compact.
1161
1242
  */
1162
1243
  export function buildCompactHashlineDiffPreview(
1163
1244
  diff: string,
1164
1245
  options: CompactHashlineDiffOptions = {},
1165
1246
  ): CompactHashlineDiffPreview {
1166
1247
  const maxUnchangedRun = options.maxUnchangedRun ?? 2;
1167
- const maxDeletionRun = options.maxDeletionRun ?? 2;
1168
- const maxOutputLines = options.maxOutputLines ?? 16;
1169
1248
 
1170
1249
  const inputLines = diff.length === 0 ? [] : diff.split("\n");
1171
- const runs = splitDiffRuns(inputLines);
1250
+ const runs = pairModifications(groupRuns(parseDiffEntries(inputLines)));
1172
1251
 
1173
1252
  const out: string[] = [];
1174
1253
  let addedLines = 0;
@@ -1178,39 +1257,41 @@ export function buildCompactHashlineDiffPreview(
1178
1257
  const run = runs[runIndex];
1179
1258
  switch (run.kind) {
1180
1259
  case "meta":
1181
- out.push(...run.lines);
1260
+ for (const entry of run.entries) out.push(formatEntry(entry));
1182
1261
  break;
1183
1262
  case "+":
1184
- addedLines += run.lines.length;
1185
- out.push(...run.lines);
1263
+ for (const entry of run.entries) {
1264
+ if (entry.kind !== "meta" && entry.content !== ELLIPSIS) addedLines++;
1265
+ out.push(formatEntry(entry));
1266
+ }
1186
1267
  break;
1187
1268
  case "-":
1188
- removedLines += run.lines.length;
1189
- out.push(...collapseFromStart(run.lines, maxDeletionRun, "removed"));
1269
+ for (const entry of run.entries) {
1270
+ if (entry.kind !== "meta" && entry.content !== ELLIPSIS) removedLines++;
1271
+ out.push(formatEntry(entry));
1272
+ }
1273
+ break;
1274
+ case "*":
1275
+ for (const entry of run.entries) {
1276
+ addedLines++;
1277
+ removedLines++;
1278
+ out.push(formatEntry(entry));
1279
+ }
1190
1280
  break;
1191
1281
  case " ":
1192
1282
  if (runIndex === 0) {
1193
- out.push(...run.lines.slice(-maxUnchangedRun));
1283
+ out.push(...run.entries.slice(-maxUnchangedRun).map(formatEntry));
1194
1284
  break;
1195
1285
  }
1196
1286
  if (runIndex === runs.length - 1) {
1197
- out.push(...run.lines.slice(0, maxUnchangedRun));
1287
+ out.push(...run.entries.slice(0, maxUnchangedRun).map(formatEntry));
1198
1288
  break;
1199
1289
  }
1200
- out.push(...collapseFromMiddle(run.lines, maxUnchangedRun, "unchanged"));
1290
+ out.push(...collapseUnchangedMiddle(run.entries, maxUnchangedRun));
1201
1291
  break;
1202
1292
  }
1203
1293
  }
1204
1294
 
1205
- if (out.length > maxOutputLines) {
1206
- const hidden = out.length - maxOutputLines;
1207
- return {
1208
- preview: [...out.slice(0, maxOutputLines), ` ... ${hidden} more preview lines`].join("\n"),
1209
- addedLines,
1210
- removedLines,
1211
- };
1212
- }
1213
-
1214
1295
  return { preview: out.join("\n"), addedLines, removedLines };
1215
1296
  }
1216
1297
 
@@ -2,13 +2,14 @@
2
2
  * Extension runner - executes extensions and manages their lifecycle.
3
3
  */
4
4
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
5
- import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
5
+ import type { ImageContent, Model, ProviderResponseMetadata } from "@oh-my-pi/pi-ai";
6
6
  import type { KeyId } from "@oh-my-pi/pi-tui";
7
7
  import { logger } from "@oh-my-pi/pi-utils";
8
8
  import type { ModelRegistry } from "../../config/model-registry";
9
9
  import { type Theme, theme } from "../../modes/theme/theme";
10
10
  import type { SessionManager } from "../../session/session-manager";
11
11
  import type {
12
+ AfterProviderResponseEvent,
12
13
  BeforeAgentStartEvent,
13
14
  BeforeAgentStartEventResult,
14
15
  BeforeProviderRequestEvent,
@@ -70,6 +71,7 @@ type RunnerEmitEvent = Exclude<
70
71
  | UserBashEvent
71
72
  | ContextEvent
72
73
  | BeforeProviderRequestEvent
74
+ | AfterProviderResponseEvent
73
75
  | BeforeAgentStartEvent
74
76
  | ResourcesDiscoverEvent
75
77
  | InputEvent
@@ -759,6 +761,37 @@ export class ExtensionRunner {
759
761
  return currentPayload;
760
762
  }
761
763
 
764
+ async emitAfterProviderResponse(response: ProviderResponseMetadata, _model?: Model): Promise<void> {
765
+ const ctx = this.createContext();
766
+
767
+ for (const ext of this.extensions) {
768
+ const handlers = ext.handlers.get("after_provider_response");
769
+ if (!handlers || handlers.length === 0) continue;
770
+
771
+ for (const handler of handlers) {
772
+ try {
773
+ const event: AfterProviderResponseEvent = {
774
+ type: "after_provider_response",
775
+ status: response.status,
776
+ headers: response.headers,
777
+ requestId: response.requestId,
778
+ metadata: response.metadata,
779
+ };
780
+ await handler(event, ctx);
781
+ } catch (err) {
782
+ const message = err instanceof Error ? err.message : String(err);
783
+ const stack = err instanceof Error ? err.stack : undefined;
784
+ this.emitError({
785
+ extensionPath: ext.path,
786
+ event: "after_provider_response",
787
+ error: message,
788
+ stack,
789
+ });
790
+ }
791
+ }
792
+ }
793
+ }
794
+
762
795
  async emitBeforeAgentStart(
763
796
  prompt: string,
764
797
  images: ImageContent[] | undefined,
@@ -17,6 +17,7 @@ import type {
17
17
  Model,
18
18
  OAuthCredentials,
19
19
  OAuthLoginCallbacks,
20
+ ProviderResponseMetadata,
20
21
  SimpleStreamOptions,
21
22
  TextContent,
22
23
  ToolResultMessage,
@@ -482,6 +483,11 @@ export interface BeforeProviderRequestEvent {
482
483
  payload: unknown;
483
484
  }
484
485
 
486
+ /** Fired after a provider response is received, before its stream body is consumed. */
487
+ export interface AfterProviderResponseEvent extends ProviderResponseMetadata {
488
+ type: "after_provider_response";
489
+ }
490
+
485
491
  /** Fired after user submits prompt but before agent loop. */
486
492
  export interface BeforeAgentStartEvent {
487
493
  type: "before_agent_start";
@@ -801,6 +807,7 @@ export type ExtensionEvent =
801
807
  | SessionEvent
802
808
  | ContextEvent
803
809
  | BeforeProviderRequestEvent
810
+ | AfterProviderResponseEvent
804
811
  | BeforeAgentStartEvent
805
812
  | AgentStartEvent
806
813
  | AgentEndEvent
@@ -981,6 +988,7 @@ export interface ExtensionAPI {
981
988
  event: "before_provider_request",
982
989
  handler: ExtensionHandler<BeforeProviderRequestEvent, BeforeProviderRequestEventResult>,
983
990
  ): void;
991
+ on(event: "after_provider_response", handler: ExtensionHandler<AfterProviderResponseEvent>): void;
984
992
  on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
985
993
  on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;
986
994
  on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
package/src/lsp/client.ts CHANGED
@@ -47,7 +47,7 @@ function startIdleChecker(): void {
47
47
  const now = Date.now();
48
48
  for (const [key, client] of Array.from(clients.entries())) {
49
49
  if (now - client.lastActivity > idleTimeoutMs) {
50
- shutdownClient(key);
50
+ void shutdownClient(key);
51
51
  }
52
52
  }
53
53
  }, IDLE_CHECK_INTERVAL_MS);
@@ -762,22 +762,25 @@ export async function refreshFile(client: LspClient, filePath: string, signal?:
762
762
  /**
763
763
  * Shutdown a specific client by key.
764
764
  */
765
- export function shutdownClient(key: string): void {
766
- const client = clients.get(key);
767
- if (!client) return;
768
-
769
- // Reject all pending requests
765
+ async function shutdownClientInstance(client: LspClient): Promise<void> {
766
+ const err = new Error("LSP client shutdown");
770
767
  for (const pending of Array.from(client.pendingRequests.values())) {
771
- pending.reject(new Error("LSP client shutdown"));
768
+ pending.reject(err);
772
769
  }
773
770
  client.pendingRequests.clear();
774
771
 
775
- // Send shutdown request (best effort, don't wait)
776
- sendRequest(client, "shutdown", null).catch(() => {});
777
-
778
- // Kill process
772
+ const timeout = Bun.sleep(5_000);
773
+ const shutdown = sendRequest(client, "shutdown", null).catch(() => {});
774
+ await Promise.race([shutdown, timeout]);
779
775
  client.proc.kill();
776
+ await Promise.race([client.proc.exited.catch(() => {}), Bun.sleep(1_000)]);
777
+ }
778
+
779
+ export async function shutdownClient(key: string): Promise<void> {
780
+ const client = clients.get(key);
781
+ if (!client) return;
780
782
  clients.delete(key);
783
+ await shutdownClientInstance(client);
781
784
  }
782
785
 
783
786
  // =============================================================================
@@ -890,27 +893,10 @@ export async function sendNotification(client: LspClient, method: string, params
890
893
  /**
891
894
  * Shutdown all LSP clients.
892
895
  */
893
- export function shutdownAll(): void {
896
+ export async function shutdownAll(): Promise<void> {
894
897
  const clientsToShutdown = Array.from(clients.values());
895
898
  clients.clear();
896
-
897
- const err = new Error("LSP client shutdown");
898
- for (const client of clientsToShutdown) {
899
- /// Reject all pending requests
900
- const reqs = Array.from(client.pendingRequests.values());
901
- client.pendingRequests.clear();
902
- for (const pending of reqs) {
903
- pending.reject(err);
904
- }
905
-
906
- void (async () => {
907
- // Send shutdown request (best effort, don't wait)
908
- const timeout = Bun.sleep(5_000);
909
- const result = sendRequest(client, "shutdown", null).catch(() => {});
910
- await Promise.race([result, timeout]);
911
- client.proc.kill();
912
- })().catch(() => {});
913
- }
899
+ await Promise.allSettled(clientsToShutdown.map(client => shutdownClientInstance(client)));
914
900
  }
915
901
 
916
902
  /** Status of an LSP server */
@@ -938,13 +924,19 @@ export function getActiveClients(): LspServerStatus[] {
938
924
 
939
925
  // Register cleanup on module unload
940
926
  if (typeof process !== "undefined") {
941
- process.on("beforeExit", shutdownAll);
927
+ process.on("beforeExit", () => {
928
+ void shutdownAll();
929
+ });
942
930
  process.on("SIGINT", () => {
943
- shutdownAll();
944
- process.exit(0);
931
+ void (async () => {
932
+ await shutdownAll();
933
+ process.exit(0);
934
+ })();
945
935
  });
946
936
  process.on("SIGTERM", () => {
947
- shutdownAll();
948
- process.exit(0);
937
+ void (async () => {
938
+ await shutdownAll();
939
+ process.exit(0);
940
+ })();
949
941
  });
950
942
  }
package/src/lsp/index.ts CHANGED
@@ -1136,7 +1136,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1136
1136
  _onUpdate?: AgentToolUpdateCallback<LspToolDetails>,
1137
1137
  _context?: AgentToolContext,
1138
1138
  ): Promise<AgentToolResult<LspToolDetails>> {
1139
- const { action, file, line, symbol, occurrence, query, new_name, apply, timeout } = params;
1139
+ const { action, file, line, symbol, query, new_name, apply, timeout } = params;
1140
1140
  const timeoutSec = clampTimeout("lsp", timeout);
1141
1141
  const timeoutSignal = AbortSignal.timeout(timeoutSec * 1000);
1142
1142
  signal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
@@ -1449,9 +1449,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1449
1449
 
1450
1450
  const uri = targetFile ? fileToUri(targetFile) : "";
1451
1451
  const resolvedLine = line ?? 1;
1452
- const resolvedCharacter = targetFile
1453
- ? await resolveSymbolColumn(targetFile, resolvedLine, symbol, occurrence)
1454
- : 0;
1452
+ const resolvedCharacter = targetFile ? await resolveSymbolColumn(targetFile, resolvedLine, symbol) : 0;
1455
1453
  const position = { line: resolvedLine - 1, character: resolvedCharacter };
1456
1454
 
1457
1455
  let output: string;
package/src/lsp/render.ts CHANGED
@@ -131,9 +131,6 @@ export function renderResult(
131
131
  }
132
132
  if (request?.symbol) {
133
133
  requestLines.push(theme.fg("dim", `symbol: ${sanitizeInlineText(request.symbol)}`));
134
- if (request.occurrence !== undefined) {
135
- requestLines.push(theme.fg("dim", `occurrence: ${request.occurrence}`));
136
- }
137
134
  }
138
135
  if (request?.query) requestLines.push(theme.fg("dim", `query: ${request.query}`));
139
136
  if (request?.new_name) requestLines.push(theme.fg("dim", `new name: ${request.new_name}`));
package/src/lsp/types.ts CHANGED
@@ -25,10 +25,7 @@ export const lspSchema = Type.Object({
25
25
  ),
26
26
  file: Type.Optional(Type.String({ description: "File path" })),
27
27
  line: Type.Optional(Type.Number({ description: "Line number (1-indexed)" })),
28
- symbol: Type.Optional(
29
- Type.String({ description: "Symbol/substring to locate on the line (used to compute column)" }),
30
- ),
31
- occurrence: Type.Optional(Type.Number({ description: "Symbol occurrence on line (1-indexed, default: 1)" })),
28
+ symbol: Type.Optional(Type.String({ description: "Symbol/substring to locate on the line" })),
32
29
  query: Type.Optional(Type.String({ description: "Search query or SSR pattern" })),
33
30
  new_name: Type.Optional(Type.String({ description: "New name for rename" })),
34
31
  apply: Type.Optional(Type.Boolean({ description: "Apply edits (default: true)" })),