@oh-my-pi/pi-coding-agent 6.8.0 → 6.8.2

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 (35) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/examples/extensions/plan-mode.ts +8 -8
  3. package/examples/extensions/tools.ts +7 -7
  4. package/package.json +6 -6
  5. package/src/cli/session-picker.ts +5 -2
  6. package/src/core/agent-session.ts +18 -5
  7. package/src/core/auth-storage.ts +13 -1
  8. package/src/core/bash-executor.ts +5 -4
  9. package/src/core/exec.ts +4 -2
  10. package/src/core/extensions/types.ts +1 -1
  11. package/src/core/hooks/types.ts +4 -3
  12. package/src/core/mcp/transports/http.ts +35 -27
  13. package/src/core/prompt-templates.ts +1 -1
  14. package/src/core/python-gateway-coordinator.ts +5 -4
  15. package/src/core/ssh/ssh-executor.ts +1 -1
  16. package/src/core/tools/lsp/client.ts +1 -1
  17. package/src/core/tools/patch/applicator.ts +38 -24
  18. package/src/core/tools/patch/diff.ts +7 -3
  19. package/src/core/tools/patch/fuzzy.ts +19 -1
  20. package/src/core/tools/patch/index.ts +4 -1
  21. package/src/core/tools/patch/types.ts +4 -0
  22. package/src/core/tools/python.ts +1 -0
  23. package/src/core/tools/task/executor.ts +100 -64
  24. package/src/core/tools/task/worker.ts +44 -14
  25. package/src/core/tools/web-fetch.ts +47 -7
  26. package/src/core/tools/web-scrapers/youtube.ts +6 -49
  27. package/src/lib/worktree/collapse.ts +3 -3
  28. package/src/lib/worktree/git.ts +6 -40
  29. package/src/lib/worktree/index.ts +1 -1
  30. package/src/main.ts +7 -5
  31. package/src/modes/interactive/components/login-dialog.ts +6 -2
  32. package/src/modes/interactive/components/tool-execution.ts +4 -0
  33. package/src/modes/interactive/interactive-mode.ts +3 -0
  34. package/src/utils/clipboard.ts +3 -5
  35. package/src/core/tools/task/model-resolver.ts +0 -206
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [6.8.2] - 2026-01-21
6
+
7
+ ### Fixed
8
+
9
+ - Improved error messages when multiple text occurrences are found by showing line previews and context
10
+ - Enhanced patch application to better handle duplicate content in context lines
11
+ - Added occurrence previews to help users disambiguate between multiple matches
12
+ - Fixed cache invalidation for streaming edits to prevent stale data
13
+ - Fixed file existence check for prompt templates directory
14
+ - Fixed bash output streaming to prevent premature stream closure
15
+ - Fixed LSP client request handling when signal is already aborted
16
+ - Fixed git apply operations with stdin input handling
17
+
18
+ ### Security
19
+
20
+ - Updated Anthropic authentication to handle manual code input securely
21
+
22
+ ## [6.8.1] - 2026-01-20
23
+
24
+ ### Fixed
25
+
26
+ - Fixed unhandled promise rejection when tool execution fails by adding missing `.catch()` to floating `.finally()` chain in `createAbortablePromise`
27
+
5
28
  ## [6.8.0] - 2026-01-20
6
29
 
7
30
  ### Added
@@ -247,16 +247,16 @@ export default function planModeExtension(pi: ExtensionAPI) {
247
247
  }
248
248
  }
249
249
 
250
- function togglePlanMode(ctx: ExtensionContext) {
250
+ async function togglePlanMode(ctx: ExtensionContext) {
251
251
  planModeEnabled = !planModeEnabled;
252
252
  executionMode = false;
253
253
  todoItems = [];
254
254
 
255
255
  if (planModeEnabled) {
256
- pi.setActiveTools(PLAN_MODE_TOOLS);
256
+ await pi.setActiveTools(PLAN_MODE_TOOLS);
257
257
  ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
258
258
  } else {
259
- pi.setActiveTools(NORMAL_MODE_TOOLS);
259
+ await pi.setActiveTools(NORMAL_MODE_TOOLS);
260
260
  ctx.ui.notify("Plan mode disabled. Full access restored.");
261
261
  }
262
262
  updateStatus(ctx);
@@ -266,7 +266,7 @@ export default function planModeExtension(pi: ExtensionAPI) {
266
266
  pi.registerCommand("plan", {
267
267
  description: "Toggle plan mode (read-only exploration)",
268
268
  handler: async (_args, ctx) => {
269
- togglePlanMode(ctx);
269
+ await togglePlanMode(ctx);
270
270
  },
271
271
  });
272
272
 
@@ -294,7 +294,7 @@ export default function planModeExtension(pi: ExtensionAPI) {
294
294
  pi.registerShortcut(Key.shift("p"), {
295
295
  description: "Toggle plan mode",
296
296
  handler: async (ctx) => {
297
- togglePlanMode(ctx);
297
+ await togglePlanMode(ctx);
298
298
  },
299
299
  });
300
300
 
@@ -417,7 +417,7 @@ Execute each step in order.`,
417
417
 
418
418
  executionMode = false;
419
419
  todoItems = [];
420
- pi.setActiveTools(NORMAL_MODE_TOOLS);
420
+ await pi.setActiveTools(NORMAL_MODE_TOOLS);
421
421
  updateStatus(ctx);
422
422
  }
423
423
  return;
@@ -470,7 +470,7 @@ Execute each step in order.`,
470
470
  if (choice?.startsWith("Execute")) {
471
471
  planModeEnabled = false;
472
472
  executionMode = hasTodos;
473
- pi.setActiveTools(NORMAL_MODE_TOOLS);
473
+ await pi.setActiveTools(NORMAL_MODE_TOOLS);
474
474
  updateStatus(ctx);
475
475
 
476
476
  // Simple execution message - context event filters old plan mode messages
@@ -519,7 +519,7 @@ Execute each step in order.`,
519
519
  }
520
520
 
521
521
  if (planModeEnabled) {
522
- pi.setActiveTools(PLAN_MODE_TOOLS);
522
+ await pi.setActiveTools(PLAN_MODE_TOOLS);
523
523
  }
524
524
  updateStatus(ctx);
525
525
  });
@@ -31,12 +31,12 @@ export default function toolsExtension(pi: ExtensionAPI) {
31
31
  }
32
32
 
33
33
  // Apply current tool selection
34
- function applyTools() {
35
- pi.setActiveTools(Array.from(enabledTools));
34
+ async function applyTools() {
35
+ await pi.setActiveTools(Array.from(enabledTools));
36
36
  }
37
37
 
38
38
  // Find the last tools-config entry in the current branch
39
- function restoreFromBranch(ctx: ExtensionContext) {
39
+ async function restoreFromBranch(ctx: ExtensionContext) {
40
40
  allTools = pi.getAllTools();
41
41
 
42
42
  // Get entries in current branch only
@@ -55,7 +55,7 @@ export default function toolsExtension(pi: ExtensionAPI) {
55
55
  if (savedTools) {
56
56
  // Restore saved tool selection (filter to only tools that still exist)
57
57
  enabledTools = new Set(savedTools.filter((t: string) => allTools.includes(t)));
58
- applyTools();
58
+ await applyTools();
59
59
  } else {
60
60
  // No saved state - sync with currently active tools
61
61
  enabledTools = new Set(pi.getActiveTools());
@@ -130,16 +130,16 @@ export default function toolsExtension(pi: ExtensionAPI) {
130
130
 
131
131
  // Restore state on session start
132
132
  pi.on("session_start", async (_event, ctx) => {
133
- restoreFromBranch(ctx);
133
+ await restoreFromBranch(ctx);
134
134
  });
135
135
 
136
136
  // Restore state when navigating the session tree
137
137
  pi.on("session_tree", async (_event, ctx) => {
138
- restoreFromBranch(ctx);
138
+ await restoreFromBranch(ctx);
139
139
  });
140
140
 
141
141
  // Restore state after branching
142
142
  pi.on("session_branch", async (_event, ctx) => {
143
- restoreFromBranch(ctx);
143
+ await restoreFromBranch(ctx);
144
144
  });
145
145
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "6.8.0",
3
+ "version": "6.8.2",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -40,11 +40,11 @@
40
40
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
41
41
  },
42
42
  "dependencies": {
43
- "@oh-my-pi/pi-agent-core": "6.8.0",
44
- "@oh-my-pi/pi-ai": "6.8.0",
45
- "@oh-my-pi/pi-git-tool": "6.8.0",
46
- "@oh-my-pi/pi-tui": "6.8.0",
47
- "@oh-my-pi/pi-utils": "6.8.0",
43
+ "@oh-my-pi/pi-agent-core": "6.8.2",
44
+ "@oh-my-pi/pi-ai": "6.8.2",
45
+ "@oh-my-pi/pi-git-tool": "6.8.2",
46
+ "@oh-my-pi/pi-tui": "6.8.2",
47
+ "@oh-my-pi/pi-utils": "6.8.2",
48
48
  "@openai/agents": "^0.3.7",
49
49
  "@sinclair/typebox": "^0.34.46",
50
50
  "ajv": "^8.17.1",
@@ -28,8 +28,11 @@ export async function selectSession(sessions: SessionInfo[]): Promise<string | n
28
28
  }
29
29
  },
30
30
  () => {
31
- ui.stop();
32
- process.exit(0);
31
+ if (!resolved) {
32
+ resolved = true;
33
+ ui.stop();
34
+ process.exit(0);
35
+ }
33
36
  },
34
37
  );
35
38
 
@@ -454,15 +454,19 @@ export class AgentSession {
454
454
  }
455
455
 
456
456
  if (event.message.role === "toolResult") {
457
- const { $normative, toolCallId } = event.message as {
457
+ const { toolName, $normative, toolCallId, details } = event.message as {
458
458
  toolName?: string;
459
459
  toolCallId?: string;
460
- details?: unknown;
460
+ details?: { path?: string };
461
461
  $normative?: Record<string, unknown>;
462
462
  };
463
463
  if ($normative && toolCallId && this.settingsManager.getNormativeRewrite()) {
464
464
  await this._rewriteToolCallArgs(toolCallId, $normative);
465
465
  }
466
+ // Invalidate streaming edit cache when edit tool completes to prevent stale data
467
+ if (toolName === "edit" && details?.path) {
468
+ this._invalidateFileCacheForPath(details.path);
469
+ }
466
470
  }
467
471
  }
468
472
 
@@ -579,11 +583,16 @@ export class AgentSession {
579
583
  this._streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
580
584
  }
581
585
  } catch {
582
- // Ignore errors - mark as empty string so we don't retry
583
- this._streamingEditFileCache.set(resolvedPath, "");
586
+ // Don't cache on read errors - let the edit tool handle them
584
587
  }
585
588
  }
586
589
 
590
+ /** Invalidate cache for a file after an edit completes to prevent stale data */
591
+ private _invalidateFileCacheForPath(path: string): void {
592
+ const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
593
+ this._streamingEditFileCache.delete(resolvedPath);
594
+ }
595
+
587
596
  private _maybeAbortStreamingEdit(event: AgentEvent): void {
588
597
  if (!this.settingsManager.getEditStreamingAbort()) return;
589
598
  if (this._streamingEditAbortTriggered) return;
@@ -2226,7 +2235,7 @@ export class AgentSession {
2226
2235
  error: message,
2227
2236
  model: `${candidate.provider}/${candidate.id}`,
2228
2237
  });
2229
- await Bun.sleep(delayMs);
2238
+ await abortableSleep(delayMs, this._autoCompactionAbortController.signal);
2230
2239
  }
2231
2240
  }
2232
2241
 
@@ -2291,6 +2300,10 @@ export class AgentSession {
2291
2300
  }, 100);
2292
2301
  }
2293
2302
  } catch (error) {
2303
+ if (this._autoCompactionAbortController?.signal.aborted) {
2304
+ this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
2305
+ return;
2306
+ }
2294
2307
  const errorMessage = error instanceof Error ? error.message : "compaction failed";
2295
2308
  this._emit({
2296
2309
  type: "auto_compaction_end",
@@ -107,10 +107,12 @@ function toBoolean(value: unknown): boolean | undefined {
107
107
  export class AuthStorage {
108
108
  private static readonly codexUsageCacheTtlMs = 60_000; // Cache usage data for 1 minute
109
109
  private static readonly defaultBackoffMs = 60_000; // Default backoff when no reset time available
110
+ private static readonly cacheCleanupIntervalMs = 300_000; // Clean expired cache every 5 minutes
110
111
 
111
112
  /** Provider -> credentials cache, populated from agent.db on reload(). */
112
113
  private data: Map<string, StoredCredential[]> = new Map();
113
114
  private storage: AgentStorage;
115
+ private lastCacheCleanup = 0;
114
116
  /** Resolved path to agent.db (derived from authPath or used directly if .db). */
115
117
  private dbPath: string;
116
118
  private runtimeOverrides: Map<string, string> = new Map();
@@ -153,6 +155,7 @@ export class AuthStorage {
153
155
  instance.sessionLastCredential = new Map();
154
156
  instance.credentialBackoff = new Map();
155
157
  instance.codexUsageCache = new Map();
158
+ instance.lastCacheCleanup = 0;
156
159
 
157
160
  for (const [provider, creds] of Object.entries(data.credentials)) {
158
161
  instance.data.set(
@@ -557,7 +560,11 @@ export class AuthStorage {
557
560
 
558
561
  switch (provider) {
559
562
  case "anthropic":
560
- credentials = await loginAnthropic(ctrl);
563
+ credentials = await loginAnthropic({
564
+ ...ctrl,
565
+ onManualCodeInput: async () =>
566
+ ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" }),
567
+ });
561
568
  break;
562
569
  case "github-copilot":
563
570
  credentials = await loginGitHubCopilot({
@@ -748,6 +755,11 @@ export class AuthStorage {
748
755
  const cacheKey = this.getCodexUsageCacheKey(accountId, normalizedBase);
749
756
  const now = Date.now();
750
757
 
758
+ if (now - this.lastCacheCleanup > AuthStorage.cacheCleanupIntervalMs) {
759
+ this.lastCacheCleanup = now;
760
+ this.storage.cleanExpiredCache();
761
+ }
762
+
751
763
  // Check in-memory cache first (fastest)
752
764
  const memCached = this.codexUsageCache.get(cacheKey);
753
765
  if (memCached && memCached.expiresAt > now) {
@@ -4,7 +4,7 @@
4
4
  * Provides unified bash execution for AgentSession.executeBash() and direct calls.
5
5
  */
6
6
 
7
- import { cspawn, Exception } from "@oh-my-pi/pi-utils";
7
+ import { cspawn, Exception, ptree } from "@oh-my-pi/pi-utils";
8
8
  import { getShellConfig } from "../utils/shell";
9
9
  import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
10
10
  import { OutputSink } from "./streaming-output";
@@ -34,7 +34,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
34
34
  const prefixedCommand = prefix ? `${prefix} ${command}` : command;
35
35
  const finalCommand = `${snapshotPrefix}${prefixedCommand}`;
36
36
 
37
- const stream = new OutputSink({ onLine: options?.onChunk });
37
+ const stream = new OutputSink({ onChunk: options?.onChunk });
38
38
 
39
39
  const child = cspawn([shell, ...args, finalCommand], {
40
40
  cwd: options?.cwd,
@@ -44,6 +44,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
44
44
  });
45
45
 
46
46
  // Pump streams - errors during abort/timeout are expected
47
+ // Use preventClose to avoid closing the shared sink when either stream finishes
47
48
  await Promise.allSettled([
48
49
  child.stdout.pipeTo(stream.createWritable()),
49
50
  child.stderr.pipeTo(stream.createWritable()),
@@ -63,7 +64,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
63
64
  // Exception covers NonZeroExitError, AbortError, TimeoutError
64
65
  if (err instanceof Exception) {
65
66
  if (err.aborted) {
66
- const isTimeout = err.message.includes("timed out");
67
+ const isTimeout = err instanceof ptree.TimeoutError || err.message.toLowerCase().includes("timed out");
67
68
  const annotation = isTimeout
68
69
  ? `Command timed out after ${Math.round((options?.timeout ?? 0) / 1000)} seconds`
69
70
  : undefined;
@@ -92,7 +93,7 @@ export async function executeBashWithOperations(
92
93
  operations: BashOperations,
93
94
  options?: BashExecutorOptions,
94
95
  ): Promise<BashResult> {
95
- const stream = new OutputSink({ onLine: options?.onChunk });
96
+ const stream = new OutputSink({ onChunk: options?.onChunk });
96
97
  const writable = stream.createWritable();
97
98
  const writer = writable.getWriter();
98
99
 
package/src/core/exec.ts CHANGED
@@ -41,14 +41,16 @@ export async function execCommand(
41
41
  signal: options?.signal,
42
42
  timeout: options?.timeout,
43
43
  });
44
+ // Read streams before awaiting exit to avoid data loss if streams close
45
+ const [stdoutText, stderrText] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
44
46
  try {
45
47
  await proc.exited;
46
48
  } catch {
47
49
  // ChildProcess rejects on non-zero exit; we handle it below
48
50
  }
49
51
  return {
50
- stdout: await proc.stdout.text(),
51
- stderr: await proc.stderr.text(),
52
+ stdout: stdoutText,
53
+ stderr: stderrText,
52
54
  code: proc.exitCode ?? 0,
53
55
  killed: proc.exitReason instanceof ptree.AbortError,
54
56
  };
@@ -757,7 +757,7 @@ export interface ExtensionAPI {
757
757
  getAllTools(): string[];
758
758
 
759
759
  /** Set the active tools by name. */
760
- setActiveTools(toolNames: string[]): void;
760
+ setActiveTools(toolNames: string[]): Promise<void>;
761
761
 
762
762
  /** Set the current model. Returns false if no API key available. */
763
763
  setModel(model: Model<any>): Promise<boolean>;
@@ -685,12 +685,13 @@ export interface HookAPI {
685
685
  * @param message.content - Message content (string or TextContent/ImageContent array)
686
686
  * @param message.display - Whether to show in TUI (true = styled display, false = hidden)
687
687
  * @param message.details - Optional hook-specific metadata (not sent to LLM)
688
- * @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
689
- * If agent is streaming, message is queued and triggerTurn is ignored.
688
+ * @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
689
+ * If agent is streaming, message is queued and triggerTurn is ignored.
690
+ * @param options.deliverAs - How to deliver the message: "steer" or "followUp".
690
691
  */
691
692
  sendMessage<T = unknown>(
692
693
  message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
693
- triggerTurn?: boolean,
694
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
694
695
  ): void;
695
696
 
696
697
  /**
@@ -5,7 +5,7 @@
5
5
  * Based on MCP spec 2025-03-26.
6
6
  */
7
7
 
8
- import type { JsonRpcResponse, MCPHttpServerConfig, MCPSseServerConfig, MCPTransport } from "../types";
8
+ import type { JsonRpcMessage, JsonRpcResponse, MCPHttpServerConfig, MCPSseServerConfig, MCPTransport } from "../types";
9
9
 
10
10
  /** Generate unique request ID */
11
11
  function generateId(): string {
@@ -167,39 +167,47 @@ export class HttpTransport implements MCPTransport {
167
167
  throw new Error("No response body");
168
168
  }
169
169
 
170
- let result: T | undefined;
170
+ const timeout = this.config.timeout ?? 30000;
171
171
 
172
- for await (const event of readSseEvents(response.body)) {
173
- const data = event.data?.trim();
174
- if (!data || data === "[DONE]") continue;
175
- try {
176
- const message = JSON.parse(data) as JsonRpcResponse;
172
+ const parse = async (): Promise<T> => {
173
+ for await (const event of readSseEvents(response.body!)) {
174
+ const data = event.data?.trim();
175
+ if (!data || data === "[DONE]") continue;
177
176
 
178
- // Handle our response
179
- if ("id" in message && message.id === expectedId) {
180
- if (message.error) {
181
- throw new Error(`MCP error ${message.error.code}: ${message.error.message}`);
177
+ try {
178
+ const message = JSON.parse(data) as JsonRpcMessage;
179
+
180
+ if (
181
+ "id" in message &&
182
+ (message as JsonRpcResponse).id === expectedId &&
183
+ ("result" in message || "error" in message)
184
+ ) {
185
+ const response = message as JsonRpcResponse;
186
+ if (response.error) {
187
+ throw new Error(`MCP error ${response.error.code}: ${response.error.message}`);
188
+ }
189
+ return response.result as T;
190
+ }
191
+
192
+ if ("method" in message && !("id" in message)) {
193
+ this.onNotification?.(message.method, message.params);
194
+ }
195
+ } catch (error) {
196
+ if (error instanceof Error && error.message.startsWith("MCP error")) {
197
+ throw error;
182
198
  }
183
- result = message.result as T;
184
- }
185
- // Handle notifications
186
- else if ("method" in message && !("id" in message)) {
187
- const notification = message as { method: string; params?: unknown };
188
- this.onNotification?.(notification.method, notification.params);
189
- }
190
- } catch (error) {
191
- if (error instanceof Error && error.message.startsWith("MCP error")) {
192
- throw error;
193
199
  }
194
- // Ignore other parse errors
195
200
  }
196
- }
197
201
 
198
- if (result === undefined) {
199
- throw new Error("No response received");
200
- }
202
+ throw new Error(`No response received for request ID ${expectedId}`);
203
+ };
201
204
 
202
- return result;
205
+ return Promise.race([
206
+ parse(),
207
+ new Promise<never>((_, reject) =>
208
+ setTimeout(() => reject(new Error(`SSE response timeout after ${timeout}ms`)), timeout),
209
+ ),
210
+ ]);
203
211
  }
204
212
 
205
213
  async notify(method: string, params?: Record<string, unknown>): Promise<void> {
@@ -432,7 +432,7 @@ async function loadTemplatesFromDir(
432
432
  }
433
433
  }
434
434
  } catch (error) {
435
- if (!Bun.file(dir).exists()) {
435
+ if (!(await Bun.file(dir).exists())) {
436
436
  return [];
437
437
  }
438
438
  logger.warn("Failed to scan prompt templates directory", { dir, error: String(error) });
@@ -116,8 +116,8 @@ const DEFAULT_ENV_DENYLIST = new Set([
116
116
  const CASE_INSENSITIVE_ENV = process.platform === "win32";
117
117
  const ACTIVE_ENV_ALLOWLIST = CASE_INSENSITIVE_ENV ? WINDOWS_ENV_ALLOWLIST : DEFAULT_ENV_ALLOWLIST;
118
118
 
119
- const NORMALIZED_ALLOWLIST = new Set(
120
- Array.from(ACTIVE_ENV_ALLOWLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
119
+ const NORMALIZED_ALLOWLIST = new Map(
120
+ Array.from(ACTIVE_ENV_ALLOWLIST, (key) => [CASE_INSENSITIVE_ENV ? key.toUpperCase() : key, key] as const),
121
121
  );
122
122
  const NORMALIZED_DENYLIST = new Set(
123
123
  Array.from(DEFAULT_ENV_DENYLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
@@ -168,8 +168,9 @@ function filterEnv(env: Record<string, string | undefined>): Record<string, stri
168
168
  if (value === undefined) continue;
169
169
  const normalizedKey = normalizeEnvKey(key);
170
170
  if (NORMALIZED_DENYLIST.has(normalizedKey)) continue;
171
- if (NORMALIZED_ALLOWLIST.has(normalizedKey)) {
172
- filtered[key] = value;
171
+ const canonicalKey = NORMALIZED_ALLOWLIST.get(normalizedKey);
172
+ if (canonicalKey !== undefined) {
173
+ filtered[canonicalKey] = value;
173
174
  continue;
174
175
  }
175
176
  if (NORMALIZED_ALLOW_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix))) {
@@ -95,7 +95,7 @@ export async function executeSSH(
95
95
  return {
96
96
  exitCode: undefined,
97
97
  cancelled: true,
98
- ...sink.dump(`SSH command timed out after ${Math.round(options!.timeout! / 1000)} seconds`),
98
+ ...sink.dump(`SSH: ${err.message}`),
99
99
  };
100
100
  }
101
101
  if (err.aborted) {
@@ -741,7 +741,7 @@ export async function sendRequest(
741
741
  signal.addEventListener("abort", abortHandler, { once: true });
742
742
  if (signal.aborted) {
743
743
  abortHandler();
744
- return;
744
+ return promise;
745
745
  }
746
746
  }
747
747
 
@@ -92,17 +92,17 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
92
92
  }
93
93
  }
94
94
 
95
- // Build a map from trimmed content to available (pattern index, actual index) pairs
96
- // This lets us find context lines and their corresponding actual content
97
- const contentToIndices = new Map<string, Array<{ patternIdx: number; actualIdx: number }>>();
98
- for (let i = 0; i < Math.min(patternLines.length, actualLines.length); i++) {
99
- const trimmed = patternLines[i].trim();
95
+ // Build a map from trimmed content to actual lines (by content, not position)
96
+ // This handles fuzzy matches where pattern and actual may not be positionally aligned
97
+ const contentToActualLines = new Map<string, string[]>();
98
+ for (const line of actualLines) {
99
+ const trimmed = line.trim();
100
100
  if (trimmed.length === 0) continue;
101
- const arr = contentToIndices.get(trimmed);
101
+ const arr = contentToActualLines.get(trimmed);
102
102
  if (arr) {
103
- arr.push({ patternIdx: i, actualIdx: i });
103
+ arr.push(line);
104
104
  } else {
105
- contentToIndices.set(trimmed, [{ patternIdx: i, actualIdx: i }]);
105
+ contentToActualLines.set(trimmed, [line]);
106
106
  }
107
107
  }
108
108
 
@@ -119,8 +119,8 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
119
119
  }
120
120
  const avgDelta = deltaCount > 0 ? Math.round(totalDelta / deltaCount) : 0;
121
121
 
122
- // Track which indices we've used to handle duplicate content correctly
123
- const usedIndices = new Set<number>();
122
+ // Track which actual lines we've used to handle duplicate content correctly
123
+ const usedActualLines = new Map<string, number>(); // trimmed content -> count used
124
124
 
125
125
  return newLines.map((newLine) => {
126
126
  if (newLine.trim().length === 0) {
@@ -128,16 +128,15 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
128
128
  }
129
129
 
130
130
  const trimmed = newLine.trim();
131
- const indices = contentToIndices.get(trimmed);
132
-
133
- // Check if this is a context line (same trimmed content exists in pattern)
134
- if (indices) {
135
- for (const { patternIdx, actualIdx } of indices) {
136
- if (!usedIndices.has(patternIdx)) {
137
- usedIndices.add(patternIdx);
138
- // Use actual file content directly for context lines
139
- return actualLines[actualIdx];
140
- }
131
+ const matchingActualLines = contentToActualLines.get(trimmed);
132
+
133
+ // Check if this is a context line (same trimmed content exists in actual)
134
+ if (matchingActualLines && matchingActualLines.length > 0) {
135
+ const usedCount = usedActualLines.get(trimmed) ?? 0;
136
+ if (usedCount < matchingActualLines.length) {
137
+ usedActualLines.set(trimmed, usedCount + 1);
138
+ // Use actual file content directly for context lines
139
+ return matchingActualLines[usedCount];
141
140
  }
142
141
  }
143
142
 
@@ -599,9 +598,11 @@ function applyCharacterMatch(
599
598
 
600
599
  // Check for multiple exact occurrences
601
600
  if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
601
+ const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
602
+ const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
602
603
  throw new ApplyPatchError(
603
- `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. ` +
604
- `The text must be unique. Please provide more context to make it unique.`,
604
+ `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\n` +
605
+ `Add more context lines to disambiguate.`,
605
606
  );
606
607
  }
607
608
 
@@ -857,9 +858,22 @@ function computeReplacements(
857
858
  if (hunk.changeContext === undefined && !hunk.hasContextLines && !hunk.isEndOfFile && lineHint === undefined) {
858
859
  const secondMatch = seekSequence(originalLines, pattern, found + 1, false, { allowFuzzy });
859
860
  if (secondMatch.index !== undefined) {
861
+ // Extract 3-line previews for each match
862
+ const formatPreview = (startIdx: number) => {
863
+ const lines = originalLines.slice(startIdx, startIdx + 3);
864
+ return lines
865
+ .map((line, i) => {
866
+ const num = startIdx + i + 1;
867
+ const truncated = line.length > 60 ? `${line.slice(0, 57)}...` : line;
868
+ return ` ${num} | ${truncated}`;
869
+ })
870
+ .join("\n");
871
+ };
872
+ const preview1 = formatPreview(found);
873
+ const preview2 = formatPreview(secondMatch.index);
860
874
  throw new ApplyPatchError(
861
- `Found 2 occurrences of the text in ${path}. ` +
862
- `The text must be unique. Please provide more context to make it unique.`,
875
+ `Found 2 occurrences in ${path}:\n\n${preview1}\n\n${preview2}\n\n` +
876
+ `Add more context lines to disambiguate.`,
863
877
  );
864
878
  }
865
879
  }
@@ -228,9 +228,11 @@ export function replaceText(content: string, oldText: string, newText: string, o
228
228
  });
229
229
 
230
230
  if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
231
+ const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
232
+ const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
231
233
  throw new Error(
232
- `Found ${matchOutcome.occurrences} occurrences of the text. ` +
233
- `The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`,
234
+ `Found ${matchOutcome.occurrences} occurrences${moreMsg}:\n\n${previews}\n\n` +
235
+ `Add more context lines to disambiguate.`,
234
236
  );
235
237
  }
236
238
 
@@ -307,8 +309,10 @@ export async function computeEditDiff(
307
309
  });
308
310
 
309
311
  if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
312
+ const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
313
+ const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
310
314
  return {
311
- error: `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`,
315
+ error: `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`,
312
316
  };
313
317
  }
314
318