@oh-my-pi/pi-coding-agent 14.5.9 → 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.
@@ -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
  }
@@ -606,27 +606,6 @@ export class HashlineMismatchError extends Error {
606
606
  "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
607
607
  );
608
608
 
609
- // Content-based recovery hint: the two-letter hash is weak, so a
610
- // unique match elsewhere is only a candidate. Keep this advisory; never
611
- // silently retarget stale edits based on a whole-file hash-only match.
612
- const hints: string[] = [];
613
- for (const m of mismatches) {
614
- const matches: number[] = [];
615
- for (let line = 1; line <= fileLines.length; line++) {
616
- if (computeLineHash(line, fileLines[line - 1]) === m.expected) matches.push(line);
617
- if (matches.length > 1) break;
618
- }
619
- if (matches.length === 1 && matches[0] !== m.line) {
620
- hints.push(` ${m.line}${m.expected} → ${matches[0]}${m.expected}`);
621
- }
622
- }
623
- if (hints.length > 0) {
624
- lines.push("Hash-only shifted candidate; verify content/context before using:");
625
- lines.push(...hints);
626
- }
627
-
628
- lines.push("");
629
-
630
609
  let prevLine = -1;
631
610
  for (const lineNum of sorted) {
632
611
  // Gap separator between non-contiguous regions
@@ -1028,23 +1007,40 @@ export interface CompactHashlineDiffPreview {
1028
1007
  }
1029
1008
 
1030
1009
  export interface CompactHashlineDiffOptions {
1010
+ /** Maximum entries kept on each side of an unchanged-context truncation (default: 2). */
1031
1011
  maxUnchangedRun?: number;
1032
- maxDeletionRun?: number;
1033
- maxOutputLines?: number;
1034
1012
  }
1035
1013
 
1036
1014
  const NUMBERED_DIFF_LINE_RE = /^([ +-])(\s*\d+)\|(.*)$/;
1037
1015
  const HASHLINE_PREVIEW_PLACEHOLDER = " ";
1016
+ const ELLIPSIS = "...";
1038
1017
 
1039
- type DiffRunKind = " " | "+" | "-" | "meta";
1040
- 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
+ }
1041
1039
 
1042
1040
  interface ParsedNumberedDiffLine {
1043
1041
  kind: " " | "+" | "-";
1044
1042
  lineNumber: number;
1045
- lineWidth: number;
1046
1043
  content: string;
1047
- raw: string;
1048
1044
  }
1049
1045
 
1050
1046
  interface CompactPreviewCounters {
@@ -1059,11 +1055,10 @@ function parseNumberedDiffLine(line: string): ParsedNumberedDiffLine | undefined
1059
1055
  const kind = match[1];
1060
1056
  if (kind !== " " && kind !== "+" && kind !== "-") return undefined;
1061
1057
 
1062
- const lineField = match[2];
1063
- const lineNumber = Number(lineField.trim());
1058
+ const lineNumber = Number(match[2].trim());
1064
1059
  if (!Number.isInteger(lineNumber)) return undefined;
1065
1060
 
1066
- return { kind, lineNumber, lineWidth: lineField.length, content: match[3], raw: line };
1061
+ return { kind, lineNumber, content: match[3] };
1067
1062
  }
1068
1063
 
1069
1064
  function syncOldLineCounters(counters: CompactPreviewCounters, lineNumber: number): void {
@@ -1090,105 +1085,169 @@ function syncNewLineCounters(counters: CompactPreviewCounters, lineNumber: numbe
1090
1085
  counters.newLine = lineNumber;
1091
1086
  }
1092
1087
 
1093
- function formatCompactHashlineLine(kind: " " | "+", lineNumber: number, content: string): string {
1094
- return `${kind}${lineNumber}${computeLineHash(lineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
1095
- }
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 = {};
1096
1097
 
1097
- function formatCompactRemovedLine(lineNumber: number, content: string): string {
1098
- return `-${lineNumber}${HASHLINE_PREVIEW_PLACEHOLDER}${HASHLINE_CONTENT_SEPARATOR}${content}`;
1099
- }
1098
+ for (const line of lines) {
1099
+ const parsed = parseNumberedDiffLine(line);
1100
+ if (!parsed) {
1101
+ entries.push({ kind: "meta", raw: line });
1102
+ continue;
1103
+ }
1100
1104
 
1101
- function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters): { kind: DiffRunKind; text: string } {
1102
- const parsed = parseNumberedDiffLine(line);
1103
- if (!parsed) return { kind: "meta", text: line };
1105
+ const isEllipsis = parsed.content === ELLIPSIS;
1104
1106
 
1105
- if (parsed.content === "...") {
1106
1107
  if (parsed.kind === "+") {
1107
1108
  syncNewLineCounters(counters, parsed.lineNumber);
1108
- } else {
1109
- 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;
1110
1114
  }
1111
- return { kind: parsed.kind, text: parsed.raw };
1112
- }
1113
1115
 
1114
- switch (parsed.kind) {
1115
- case "+": {
1116
- syncNewLineCounters(counters, parsed.lineNumber);
1117
- const newLine = counters.newLine;
1118
- if (newLine === undefined) return { kind: "+", text: parsed.raw };
1119
- const text = formatCompactHashlineLine("+", newLine, parsed.content);
1120
- counters.newLine = newLine + 1;
1121
- return { kind: "+", text };
1122
- }
1123
- case "-": {
1116
+ if (parsed.kind === "-") {
1124
1117
  syncOldLineCounters(counters, parsed.lineNumber);
1125
- const text = formatCompactRemovedLine(parsed.lineNumber, parsed.content);
1126
- counters.oldLine = parsed.lineNumber + 1;
1127
- 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;
1128
1123
  }
1129
- case " ": {
1130
- syncOldLineCounters(counters, parsed.lineNumber);
1131
- const newLine = counters.newLine;
1132
- if (newLine === undefined) return { kind: " ", text: parsed.raw };
1133
- const text = formatCompactHashlineLine(" ", newLine, parsed.content);
1134
- 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;
1135
1132
  counters.newLine = newLine + 1;
1136
- return { kind: " ", text };
1137
1133
  }
1138
1134
  }
1139
- }
1140
1135
 
1141
- function splitDiffRuns(lines: string[]): DiffRun[] {
1142
- const runs: DiffRun[] = [];
1143
- const counters: CompactPreviewCounters = {};
1136
+ return entries;
1137
+ }
1144
1138
 
1145
- for (const line of lines) {
1146
- const formatted = formatCompactPreviewLine(line, counters);
1139
+ function groupRuns(entries: Entry[]): Run[] {
1140
+ const runs: Run[] = [];
1141
+ for (const entry of entries) {
1147
1142
  const prev = runs[runs.length - 1];
1148
- if (prev && prev.kind === formatted.kind) {
1149
- prev.lines.push(formatted.text);
1143
+ if (prev && prev.kind === entry.kind) {
1144
+ prev.entries.push(entry);
1150
1145
  continue;
1151
1146
  }
1152
- runs.push({ kind: formatted.kind, lines: [formatted.text] });
1147
+ runs.push({ kind: entry.kind, entries: [entry] });
1153
1148
  }
1154
-
1155
1149
  return runs;
1156
1150
  }
1157
1151
 
1158
- function collapseFromStart(lines: string[], maxLines: number, label: string): string[] {
1159
- if (lines.length <= maxLines) return lines;
1160
- const hidden = lines.length - maxLines;
1161
- 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;
1162
1201
  }
1163
1202
 
1164
- function _collapseFromEnd(lines: string[], maxLines: number, label: string): string[] {
1165
- if (lines.length <= maxLines) return lines;
1166
- const hidden = lines.length - maxLines;
1167
- 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
+ }
1168
1223
  }
1169
1224
 
1170
- function collapseFromMiddle(lines: string[], maxLines: number, label: string): string[] {
1171
- if (lines.length <= maxLines * 2) return lines;
1172
- const hidden = lines.length - maxLines * 2;
1173
- 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
+ ];
1174
1233
  }
1175
1234
 
1176
1235
  /**
1177
1236
  * Build a compact diff preview suitable for model-visible tool responses.
1178
1237
  *
1179
- * Collapses long unchanged runs and long consecutive additions/removals so the
1180
- * 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.
1181
1242
  */
1182
1243
  export function buildCompactHashlineDiffPreview(
1183
1244
  diff: string,
1184
1245
  options: CompactHashlineDiffOptions = {},
1185
1246
  ): CompactHashlineDiffPreview {
1186
1247
  const maxUnchangedRun = options.maxUnchangedRun ?? 2;
1187
- const maxDeletionRun = options.maxDeletionRun ?? 2;
1188
- const maxOutputLines = options.maxOutputLines ?? 16;
1189
1248
 
1190
1249
  const inputLines = diff.length === 0 ? [] : diff.split("\n");
1191
- const runs = splitDiffRuns(inputLines);
1250
+ const runs = pairModifications(groupRuns(parseDiffEntries(inputLines)));
1192
1251
 
1193
1252
  const out: string[] = [];
1194
1253
  let addedLines = 0;
@@ -1198,39 +1257,41 @@ export function buildCompactHashlineDiffPreview(
1198
1257
  const run = runs[runIndex];
1199
1258
  switch (run.kind) {
1200
1259
  case "meta":
1201
- out.push(...run.lines);
1260
+ for (const entry of run.entries) out.push(formatEntry(entry));
1202
1261
  break;
1203
1262
  case "+":
1204
- addedLines += run.lines.length;
1205
- 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
+ }
1206
1267
  break;
1207
1268
  case "-":
1208
- removedLines += run.lines.length;
1209
- 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
+ }
1210
1280
  break;
1211
1281
  case " ":
1212
1282
  if (runIndex === 0) {
1213
- out.push(...run.lines.slice(-maxUnchangedRun));
1283
+ out.push(...run.entries.slice(-maxUnchangedRun).map(formatEntry));
1214
1284
  break;
1215
1285
  }
1216
1286
  if (runIndex === runs.length - 1) {
1217
- out.push(...run.lines.slice(0, maxUnchangedRun));
1287
+ out.push(...run.entries.slice(0, maxUnchangedRun).map(formatEntry));
1218
1288
  break;
1219
1289
  }
1220
- out.push(...collapseFromMiddle(run.lines, maxUnchangedRun, "unchanged"));
1290
+ out.push(...collapseUnchangedMiddle(run.entries, maxUnchangedRun));
1221
1291
  break;
1222
1292
  }
1223
1293
  }
1224
1294
 
1225
- if (out.length > maxOutputLines) {
1226
- const hidden = out.length - maxOutputLines;
1227
- return {
1228
- preview: [...out.slice(0, maxOutputLines), ` ... ${hidden} more preview lines`].join("\n"),
1229
- addedLines,
1230
- removedLines,
1231
- };
1232
- }
1233
-
1234
1295
  return { preview: out.join("\n"), addedLines, removedLines };
1235
1296
  }
1236
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
  }
@@ -277,6 +277,11 @@ async function runPhase1(options: {
277
277
  });
278
278
 
279
279
  if (result.kind === "failed") {
280
+ logger.error("Memory phase1 stage1 job failed", {
281
+ threadId: claim.threadId,
282
+ rolloutPath: claim.rolloutPath,
283
+ reason: result.reason,
284
+ });
280
285
  markStage1Failed(db, {
281
286
  threadId: claim.threadId,
282
287
  ownershipToken: claim.ownershipToken,
@@ -348,7 +348,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
348
348
  { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
349
349
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
350
350
  { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
351
- { value: "searxng", label: "SearXNG", description: "Self-hosted metasearch; set searxng.endpoint" },
351
+ { value: "searxng", label: "SearXNG", description: "Requires searxng.endpoint" },
352
352
  ],
353
353
  "providers.image": [
354
354
  {
@@ -659,9 +659,9 @@ export class SelectorController {
659
659
  return;
660
660
  }
661
661
 
662
- // Update UI
662
+ // Update UI — pass the context built by navigateTree to skip a second O(N) walk.
663
663
  this.ctx.chatContainer.clear();
664
- this.ctx.renderInitialMessages();
664
+ this.ctx.renderInitialMessages(result.sessionContext);
665
665
  await this.ctx.reloadTodos();
666
666
  if (result.editorText && !this.ctx.editor.getText().trim()) {
667
667
  this.ctx.editor.setText(result.editorText);