@rama_nigg/open-cursor 2.4.4 → 2.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rama_nigg/open-cursor",
3
- "version": "2.4.4",
3
+ "version": "2.4.6",
4
4
  "description": "No prompt limits. No broken streams. Full thinking + tool support. Your Cursor subscription, properly integrated.",
5
5
  "type": "module",
6
6
  "main": "dist/plugin-entry.js",
package/src/auth.ts CHANGED
@@ -6,7 +6,7 @@ import { homedir, platform } from "os";
6
6
  import { join } from "path";
7
7
  import { createLogger } from "./utils/logger";
8
8
  import { stripAnsi } from "./utils/errors";
9
- import { resolveCursorAgentBinary } from "./utils/binary.js";
9
+ import { formatShellCommandForPlatform, resolveCursorAgentBinary } from "./utils/binary.js";
10
10
 
11
11
  const log = createLogger("auth");
12
12
 
@@ -76,7 +76,7 @@ export async function startCursorOAuth(): Promise<{
76
76
  return new Promise((resolve, reject) => {
77
77
  log.info("Starting cursor-cli login process");
78
78
 
79
- const proc = spawn(resolveCursorAgentBinary(), ["login"], {
79
+ const proc = spawn(formatShellCommandForPlatform(resolveCursorAgentBinary()), ["login"], {
80
80
  stdio: ["pipe", "pipe", "pipe"],
81
81
  shell: process.platform === "win32",
82
82
  });
@@ -7,7 +7,7 @@ import {
7
7
  type StreamJsonEvent,
8
8
  } from '../streaming/types.js';
9
9
  import { createLogger } from '../utils/logger.js';
10
- import { resolveCursorAgentBinary } from '../utils/binary.js';
10
+ import { formatShellCommandForPlatform, resolveCursorAgentBinary } from '../utils/binary.js';
11
11
 
12
12
  export interface CursorClientConfig {
13
13
  timeout?: number;
@@ -77,7 +77,7 @@ export class SimpleCursorClient {
77
77
 
78
78
  this.log.debug('Executing prompt stream', { promptLength: prompt.length, mode, model });
79
79
 
80
- const child = spawn(this.config.cursorAgentPath, args, {
80
+ const child = spawn(formatShellCommandForPlatform(this.config.cursorAgentPath), args, {
81
81
  cwd,
82
82
  stdio: ['pipe', 'pipe', 'pipe'],
83
83
  shell: process.platform === 'win32',
@@ -189,7 +189,7 @@ export class SimpleCursorClient {
189
189
  this.log.debug('Executing prompt', { promptLength: prompt.length, mode, model });
190
190
 
191
191
  return new Promise((resolve, reject) => {
192
- const child = spawn(this.config.cursorAgentPath, args, {
192
+ const child = spawn(formatShellCommandForPlatform(this.config.cursorAgentPath), args, {
193
193
  cwd,
194
194
  stdio: ['pipe', 'pipe', 'pipe'],
195
195
  shell: process.platform === 'win32',
@@ -4,6 +4,8 @@ import type { McpClientManager } from "./client-manager.js";
4
4
 
5
5
  const log = createLogger("mcp:tool-bridge");
6
6
 
7
+ export const MCP_TOOL_PREFIX = "mcp__";
8
+
7
9
  interface DiscoveredMcpTool {
8
10
  name: string;
9
11
  serverName: string;
@@ -76,10 +78,10 @@ export function buildMcpToolDefinitions(tools: DiscoveredMcpTool[]): any[] {
76
78
  return defs;
77
79
  }
78
80
 
79
- function namespaceMcpTool(serverName: string, toolName: string): string {
81
+ export function namespaceMcpTool(serverName: string, toolName: string): string {
80
82
  const sanitizedServer = serverName.replace(/[^a-zA-Z0-9]/g, "_");
81
83
  const sanitizedTool = toolName.replace(/[^a-zA-Z0-9]/g, "_");
82
- return `mcp__${sanitizedServer}__${sanitizedTool}`;
84
+ return `${MCP_TOOL_PREFIX}${sanitizedServer}__${sanitizedTool}`;
83
85
  }
84
86
 
85
87
  function mcpSchemaToZod(inputSchema: Record<string, unknown> | undefined, z: any): any {
package/src/plugin.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import type { Plugin, PluginInput } from "@opencode-ai/plugin";
2
2
  import { tool } from "@opencode-ai/plugin";
3
3
  import type { Auth } from "@opencode-ai/sdk";
4
- import { appendFileSync, existsSync, mkdirSync, realpathSync } from "fs";
4
+ import { realpathSync } from "fs";
5
5
  import { mkdir } from "fs/promises";
6
6
  import { homedir } from "os";
7
7
  import { isAbsolute, join, relative, resolve } from "path";
8
8
  import { ToolMapper, type ToolUpdate } from "./acp/tools.js";
9
9
  import { startCursorOAuth } from "./auth";
10
10
  import { LineBuffer } from "./streaming/line-buffer.js";
11
+ import { MixedDeltaTracker } from "./streaming/delta-tracker.js";
11
12
  import { StreamToSseConverter, formatSseDone } from "./streaming/openai-sse.js";
12
13
  import { parseStreamJsonLine } from "./streaming/parser.js";
13
14
  import { extractText, extractThinking, isAssistantText, isResult, isThinking } from "./streaming/types.js";
@@ -32,7 +33,12 @@ import { SkillResolver } from "./tools/skills/resolver.js";
32
33
  import { autoRefreshModels } from "./models/sync.js";
33
34
  import { readMcpConfigs, readSubagentNames } from "./mcp/config.js";
34
35
  import { McpClientManager } from "./mcp/client-manager.js";
35
- import { buildMcpToolHookEntries, buildMcpToolDefinitions } from "./mcp/tool-bridge.js";
36
+ import {
37
+ MCP_TOOL_PREFIX,
38
+ buildMcpToolHookEntries,
39
+ buildMcpToolDefinitions,
40
+ namespaceMcpTool,
41
+ } from "./mcp/tool-bridge.js";
36
42
  import { createOpencodeClient } from "@opencode-ai/sdk";
37
43
  import { ToolRegistry as CoreRegistry } from "./tools/core/registry.js";
38
44
  import { LocalExecutor } from "./tools/executors/local.js";
@@ -57,42 +63,23 @@ import {
57
63
  parseToolLoopMaxRepeat,
58
64
  type ToolLoopGuard,
59
65
  } from "./provider/tool-loop-guard.js";
60
- import { resolveCursorAgentBinary } from "./utils/binary.js";
66
+ import { formatShellCommandForPlatform, resolveCursorAgentBinary } from "./utils/binary.js";
61
67
 
62
68
  const log = createLogger("plugin");
63
69
 
64
- // Debug log file for tool-loop investigation
65
- const DEBUG_LOG_DIR = join(homedir(), ".config", "opencode", "logs");
66
- const DEBUG_LOG_FILE = join(DEBUG_LOG_DIR, "tool-loop-debug.log");
67
-
68
- function ensureDebugLogDir(): void {
69
- try {
70
- if (!existsSync(DEBUG_LOG_DIR)) {
71
- mkdir(DEBUG_LOG_DIR, { recursive: true }).catch(() => {});
72
- }
73
- } catch {
74
- // Ignore errors creating log directory
75
- }
76
- }
77
-
78
- function debugLogToFile(message: string, data: any): void {
79
- try {
80
- ensureDebugLogDir();
81
- const timestamp = new Date().toISOString();
82
- const logLine = `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n`;
83
- appendFileSync(DEBUG_LOG_FILE, logLine);
84
- } catch {
85
- // Ignore errors
86
- }
87
- }
88
-
89
70
  interface McpToolSummary {
90
71
  serverName: string;
91
72
  toolName: string;
73
+ callName?: string;
92
74
  description?: string;
93
75
  params?: string[];
94
76
  }
95
77
 
78
+ function getMcpToolDefinitionName(mcpToolDefs: any[], index: number): string | undefined {
79
+ const name = mcpToolDefs[index]?.function?.name;
80
+ return typeof name === "string" && name.length > 0 ? name : undefined;
81
+ }
82
+
96
83
  export function buildAvailableToolsSystemMessage(
97
84
  lastToolNames: string[],
98
85
  lastToolMap: Array<{ id: string; name: string }>,
@@ -109,16 +96,23 @@ export function buildAvailableToolsSystemMessage(
109
96
  }
110
97
 
111
98
  if (mcpToolSummaries && mcpToolSummaries.length > 0) {
112
- const servers = new Map<string, McpToolSummary[]>();
113
- for (const s of mcpToolSummaries) {
99
+ const summariesWithCallNames = mcpToolSummaries.map((summary, index) => ({
100
+ ...summary,
101
+ callName: summary.callName
102
+ ?? getMcpToolDefinitionName(mcpToolDefs, index)
103
+ ?? namespaceMcpTool(summary.serverName, summary.toolName),
104
+ }));
105
+
106
+ const servers = new Map<string, Array<McpToolSummary & { callName: string }>>();
107
+ for (const s of summariesWithCallNames) {
114
108
  const list = servers.get(s.serverName) ?? [];
115
109
  list.push(s);
116
110
  servers.set(s.serverName, list);
117
111
  }
118
112
 
119
113
  const lines: string[] = [
120
- "MCP TOOLS — Use via Shell with the `mcptool` CLI.",
121
- "Syntax: mcptool call <server> <tool> [json-args]",
114
+ `MCP TOOLS — Use via direct tool calls (\`${MCP_TOOL_PREFIX}<server>__<tool>\`).`,
115
+ "These tools are exposed as first-class tool calls (e.g. mcp__filesystem__read_file).",
122
116
  "",
123
117
  ];
124
118
 
@@ -126,12 +120,8 @@ export function buildAvailableToolsSystemMessage(
126
120
  lines.push(`Server: ${server}`);
127
121
  for (const t of tools) {
128
122
  const paramHint = t.params?.length ? ` (params: ${t.params.join(", ")})` : "";
129
- lines.push(` - ${t.toolName}${paramHint}${t.description ? " " + t.description : ""}`);
130
- }
131
- if (tools.length > 0) {
132
- const ex = tools[0];
133
- const exArgs = ex.params?.length ? ` '{"${ex.params[0]}":"..."}'` : "";
134
- lines.push(` Example: mcptool call ${server} ${ex.toolName}${exArgs}`);
123
+ const sourceHint = t.callName === t.toolName ? "" : ` (server: ${t.serverName}; tool: ${t.toolName})`;
124
+ lines.push(` - ${t.callName}${paramHint}${t.description ? " — " + t.description : ""}${sourceHint}`);
135
125
  }
136
126
  lines.push("");
137
127
  }
@@ -444,6 +434,7 @@ export function extractCompletionFromStream(output: string): {
444
434
  let usage: OpenAiUsage | undefined;
445
435
  let sawAssistantPartials = false;
446
436
  let sawThinkingPartials = false;
437
+ const tracker = new MixedDeltaTracker();
447
438
 
448
439
  for (const line of lines) {
449
440
  const event = parseStreamJsonLine(line);
@@ -457,8 +448,8 @@ export function extractCompletionFromStream(output: string): {
457
448
 
458
449
  const isPartial = typeof (event as any).timestamp_ms === "number";
459
450
  if (isPartial) {
460
- assistantText += text;
461
451
  sawAssistantPartials = true;
452
+ assistantText += tracker.nextText(text);
462
453
  } else if (!sawAssistantPartials) {
463
454
  assistantText = text;
464
455
  }
@@ -469,8 +460,8 @@ export function extractCompletionFromStream(output: string): {
469
460
  if (thinking) {
470
461
  const isPartial = typeof (event as any).timestamp_ms === "number";
471
462
  if (isPartial) {
472
- reasoningText += thinking;
473
463
  sawThinkingPartials = true;
464
+ reasoningText += tracker.nextThinking(thinking);
474
465
  } else if (!sawThinkingPartials) {
475
466
  reasoningText = thinking;
476
467
  }
@@ -705,8 +696,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
705
696
  const stream = body?.stream === true;
706
697
  const tools = Array.isArray(body?.tools) ? body.tools : [];
707
698
 
708
- // DEBUG: Log raw request structure for tool-loop investigation
709
- debugLogToFile("raw_request_body", {
699
+ log.debug("raw request body", {
710
700
  model: body?.model,
711
701
  cursorModel: body?.cursorModel,
712
702
  stream,
@@ -1238,7 +1228,7 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1238
1228
  cmd.push("--force");
1239
1229
  }
1240
1230
 
1241
- const child = spawn(cmd[0], cmd.slice(1), {
1231
+ const child = spawn(formatShellCommandForPlatform(cmd[0]), cmd.slice(1), {
1242
1232
  stdio: ["pipe", "pipe", "pipe"],
1243
1233
  shell: process.platform === "win32",
1244
1234
  });
@@ -1415,19 +1405,17 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1415
1405
  }
1416
1406
  };
1417
1407
 
1418
- child.stdout.on("data", async (chunk) => {
1419
- if (streamTerminated || res.writableEnded) {
1420
- return;
1421
- }
1422
- if (!firstTokenReceived) { perf.mark("first-token"); firstTokenReceived = true; }
1423
- for (const line of lineBuffer.push(chunk)) {
1424
- if (streamTerminated || res.writableEnded) {
1425
- break;
1426
- }
1408
+ const chunkQueue: Buffer[] = [];
1409
+ let draining = false;
1410
+ let childClosed = false;
1411
+ let childCloseHandled = false;
1412
+ let childExitCode: number | null = null;
1413
+
1414
+ const processLines = async (lines: string[]) => {
1415
+ for (const line of lines) {
1416
+ if (streamTerminated || res.writableEnded) break;
1427
1417
  const event = parseStreamJsonLine(line);
1428
- if (!event) {
1429
- continue;
1430
- }
1418
+ if (!event) continue;
1431
1419
 
1432
1420
  if (isResult(event)) {
1433
1421
  usage = extractOpenAiUsageFromResult(event) ?? usage;
@@ -1469,156 +1457,104 @@ async function ensureCursorProxyServer(workspaceDirectory: string, toolRouter?:
1469
1457
  if (!result.terminate.silent) {
1470
1458
  emitTerminalAssistantErrorAndTerminate(result.terminate.message);
1471
1459
  } else {
1472
- // Silent termination: just end the stream without an error message
1473
1460
  streamTerminated = true;
1474
1461
  try { child.kill(); } catch { /* ignore */ }
1475
1462
  }
1476
1463
  break;
1477
1464
  }
1478
- if (result.intercepted) {
1479
- break;
1480
- }
1481
- if (result.skipConverter) {
1482
- continue;
1483
- }
1465
+ if (result.intercepted) break;
1466
+ if (result.skipConverter) continue;
1484
1467
  }
1485
1468
 
1486
- if (streamTerminated || res.writableEnded) {
1487
- break;
1488
- }
1469
+ if (streamTerminated || res.writableEnded) break;
1489
1470
  for (const sse of converter.handleEvent(event)) {
1490
1471
  res.write(sse);
1491
1472
  }
1492
1473
  }
1493
- });
1494
-
1495
- child.on("close", async (code) => {
1496
- if (streamTerminated || res.writableEnded) {
1497
- return;
1498
- }
1499
- for (const line of lineBuffer.flush()) {
1500
- if (streamTerminated || res.writableEnded) {
1501
- break;
1502
- }
1503
- const event = parseStreamJsonLine(line);
1504
- if (!event) {
1505
- continue;
1506
- }
1474
+ };
1507
1475
 
1508
- if (isResult(event)) {
1509
- usage = extractOpenAiUsageFromResult(event) ?? usage;
1476
+ const drainQueue = async () => {
1477
+ if (draining) return;
1478
+ draining = true;
1479
+ try {
1480
+ while (chunkQueue.length > 0) {
1481
+ if (streamTerminated || res.writableEnded) break;
1482
+ const chunk = chunkQueue.shift()!;
1483
+ if (!firstTokenReceived) { perf.mark("first-token"); firstTokenReceived = true; }
1484
+ await processLines(lineBuffer.push(chunk));
1510
1485
  }
1511
1486
 
1512
- if (event.type === "tool_call") {
1513
- const result = await handleToolLoopEventWithFallback({
1514
- event: event as any,
1515
- boundary: boundaryContext.getBoundary(),
1516
- boundaryMode: boundaryContext.getBoundary().mode,
1517
- autoFallbackToLegacy: ENABLE_PROVIDER_BOUNDARY_AUTOFALLBACK,
1518
- toolLoopMode: TOOL_LOOP_MODE,
1519
- allowedToolNames,
1520
- toolSchemaMap,
1521
- toolLoopGuard,
1522
- toolMapper,
1523
- toolSessionId,
1524
- shouldEmitToolUpdates: SHOULD_EMIT_TOOL_UPDATES,
1525
- proxyExecuteToolCalls: PROXY_EXECUTE_TOOL_CALLS,
1526
- suppressConverterToolEvents: SUPPRESS_CONVERTER_TOOL_EVENTS,
1527
- toolRouter,
1528
- responseMeta: { id, created, model },
1529
- passThroughTracker,
1530
- onToolUpdate: (update) => {
1531
- res.write(formatToolUpdateEvent(update));
1532
- },
1533
- onToolResult: (toolResult) => {
1534
- res.write(`data: ${JSON.stringify(toolResult)}\n\n`);
1535
- },
1536
- onInterceptedToolCall: (toolCall) => {
1537
- emitToolCallAndTerminate(toolCall);
1538
- },
1539
- onFallbackToLegacy: (error) => {
1540
- boundaryContext.activateLegacyFallback("handleToolLoopEvent.close", error);
1541
- },
1487
+ if (childClosed && !childCloseHandled && !streamTerminated && !res.writableEnded) {
1488
+ childCloseHandled = true;
1489
+ await processLines(lineBuffer.flush());
1490
+ if (streamTerminated || res.writableEnded) return;
1491
+
1492
+ perf.mark("request:done");
1493
+ perf.summarize();
1494
+ const stderrText = Buffer.concat(stderrChunks).toString().trim();
1495
+ log.debug("cursor-agent completed (node stream)", {
1496
+ code: childExitCode,
1497
+ stderrChars: stderrText.length,
1542
1498
  });
1543
- if (result.terminate) {
1544
- if (!result.terminate.silent) {
1545
- emitTerminalAssistantErrorAndTerminate(result.terminate.message);
1546
- } else {
1547
- // Silent termination: just end the stream without an error message
1548
- streamTerminated = true;
1549
- try { child.kill(); } catch { /* ignore */ }
1550
- }
1551
- break;
1499
+ if (childExitCode !== 0) {
1500
+ const errSource =
1501
+ stderrText
1502
+ || `cursor-agent exited with code ${String(childExitCode ?? "unknown")} and no output`;
1503
+ const parsed = parseAgentError(errSource);
1504
+ const msg = formatErrorForUser(parsed);
1505
+ const errChunk = createChatCompletionChunk(id, created, model, msg, true);
1506
+ res.write(`data: ${JSON.stringify(errChunk)}\n\n`);
1507
+ res.write(formatSseDone());
1508
+ streamTerminated = true;
1509
+ res.end();
1510
+ return;
1552
1511
  }
1553
- if (result.intercepted) {
1554
- break;
1512
+
1513
+ const passThroughSummary = passThroughTracker.getSummary();
1514
+ if (passThroughSummary.hasActivity) {
1515
+ await toastService.showPassThroughSummary(passThroughSummary.tools);
1555
1516
  }
1556
- if (result.skipConverter) {
1557
- continue;
1517
+ if (passThroughSummary.errors.length > 0) {
1518
+ await toastService.showErrorSummary(passThroughSummary.errors);
1558
1519
  }
1559
- }
1560
1520
 
1561
- if (streamTerminated || res.writableEnded) {
1562
- break;
1521
+ const doneChunk = {
1522
+ id,
1523
+ object: "chat.completion.chunk",
1524
+ created,
1525
+ model,
1526
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
1527
+ };
1528
+ res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
1529
+ if (usage) {
1530
+ const usageChunk = createChatCompletionUsageChunk(id, created, model, usage);
1531
+ res.write(`data: ${JSON.stringify(usageChunk)}\n\n`);
1532
+ }
1533
+ res.write(formatSseDone());
1534
+ streamTerminated = true;
1535
+ res.end();
1563
1536
  }
1564
- for (const sse of converter.handleEvent(event)) {
1565
- res.write(sse);
1537
+ } finally {
1538
+ draining = false;
1539
+ if (
1540
+ !streamTerminated
1541
+ && !res.writableEnded
1542
+ && (chunkQueue.length > 0 || (childClosed && !childCloseHandled))
1543
+ ) {
1544
+ drainQueue();
1566
1545
  }
1567
1546
  }
1568
- if (streamTerminated || res.writableEnded) {
1569
- return;
1570
- }
1571
-
1572
- perf.mark("request:done");
1573
- perf.summarize();
1574
- const stderrText = Buffer.concat(stderrChunks).toString().trim();
1575
- log.debug("cursor-agent completed (node stream)", {
1576
- code,
1577
- stderrChars: stderrText.length,
1578
- });
1579
- if (code !== 0) {
1580
- const errSource =
1581
- stderrText
1582
- || `cursor-agent exited with code ${String(code ?? "unknown")} and no output`;
1583
- const parsed = parseAgentError(errSource);
1584
- const msg = formatErrorForUser(parsed);
1585
- const errChunk = createChatCompletionChunk(id, created, model, msg, true);
1586
- res.write(`data: ${JSON.stringify(errChunk)}\n\n`);
1587
- res.write(formatSseDone());
1588
- streamTerminated = true;
1589
- res.end();
1590
- return;
1591
- }
1547
+ };
1592
1548
 
1593
- // Emit toast for passed-through MCP tools
1594
- const passThroughSummary = passThroughTracker.getSummary();
1595
- if (passThroughSummary.hasActivity) {
1596
- await toastService.showPassThroughSummary(passThroughSummary.tools);
1597
- }
1598
- if (passThroughSummary.errors.length > 0) {
1599
- await toastService.showErrorSummary(passThroughSummary.errors);
1600
- }
1549
+ child.stdout.on("data", (chunk) => {
1550
+ chunkQueue.push(Buffer.from(chunk));
1551
+ drainQueue();
1552
+ });
1601
1553
 
1602
- const doneChunk = {
1603
- id,
1604
- object: "chat.completion.chunk",
1605
- created,
1606
- model,
1607
- choices: [
1608
- {
1609
- index: 0,
1610
- delta: {},
1611
- finish_reason: "stop",
1612
- },
1613
- ],
1614
- };
1615
- res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
1616
- if (usage) {
1617
- const usageChunk = createChatCompletionUsageChunk(id, created, model, usage);
1618
- res.write(`data: ${JSON.stringify(usageChunk)}\n\n`);
1619
- }
1620
- res.write(formatSseDone());
1621
- res.end();
1554
+ child.on("close", (code) => {
1555
+ childClosed = true;
1556
+ childExitCode = code;
1557
+ drainQueue();
1622
1558
  });
1623
1559
  }
1624
1560
  } catch (error) {
@@ -1955,6 +1891,7 @@ export const CursorPlugin: Plugin = async ({ $, directory, worktree, client, ser
1955
1891
  mcpToolSummaries = tools.map((t) => ({
1956
1892
  serverName: t.serverName,
1957
1893
  toolName: t.name,
1894
+ callName: namespaceMcpTool(t.serverName, t.name),
1958
1895
  description: t.description,
1959
1896
  params: t.inputSchema
1960
1897
  ? Object.keys((t.inputSchema as any).properties ?? {})
@@ -3,7 +3,11 @@ import { extractOpenAiToolCall, type OpenAiToolCall, type ToolCallExtractionResu
3
3
  import type { StreamJsonToolCallEvent } from "../streaming/types.js";
4
4
  import type { ToolRouter } from "../tools/router.js";
5
5
  import { createLogger } from "../utils/logger.js";
6
- import { applyToolSchemaCompat, type ToolSchemaValidationResult } from "./tool-schema-compat.js";
6
+ import {
7
+ applyToolSchemaCompat,
8
+ tryRerouteEditToWrite,
9
+ type ToolSchemaValidationResult,
10
+ } from "./tool-schema-compat.js";
7
11
  import type { ToolLoopGuard } from "./tool-loop-guard.js";
8
12
  import type { ProviderBoundaryMode, ToolLoopMode } from "./boundary.js";
9
13
  import type { ProviderBoundary } from "./boundary.js";
@@ -190,6 +194,20 @@ export async function handleToolLoopEventLegacy(
190
194
  return { intercepted: false, skipConverter: true, terminate: validationTermination };
191
195
  }
192
196
 
197
+ const reroutedWrite = tryRerouteEditToWrite(
198
+ normalizedToolCall,
199
+ compat,
200
+ allowedToolNames,
201
+ toolSchemaMap,
202
+ );
203
+ if (reroutedWrite) {
204
+ log.debug("Rerouting malformed edit call to write (legacy)", {
205
+ missing: compat.validation.missing,
206
+ });
207
+ await onInterceptedToolCall(reroutedWrite);
208
+ return { intercepted: true, skipConverter: true };
209
+ }
210
+
193
211
  if (shouldEmitNonFatalSchemaValidationHint(normalizedToolCall, compat.validation)) {
194
212
  const hintChunk = createNonFatalSchemaValidationHintChunk(
195
213
  responseMeta,
@@ -383,6 +401,23 @@ export async function handleToolLoopEventV1(
383
401
  terminate: createSchemaValidationTermination(normalizedToolCall, compat.validation),
384
402
  };
385
403
  }
404
+ const reroutedWrite = tryRerouteEditToWrite(
405
+ normalizedToolCall,
406
+ compat,
407
+ allowedToolNames,
408
+ toolSchemaMap,
409
+ );
410
+ if (reroutedWrite) {
411
+ log.debug("Rerouting malformed edit call to write", {
412
+ missing: compat.validation.missing,
413
+ typeErrors: compat.validation.typeErrors,
414
+ });
415
+ await onInterceptedToolCall(reroutedWrite);
416
+ return {
417
+ intercepted: true,
418
+ skipConverter: true,
419
+ };
420
+ }
386
421
  if (
387
422
  schemaValidationFailureMode === "pass_through"
388
423
  && shouldEmitNonFatalSchemaValidationHint(normalizedToolCall, compat.validation)
@@ -668,11 +703,11 @@ function createNonFatalSchemaValidationHintChunk(
668
703
  validation: ToolSchemaValidationResult,
669
704
  ): NonFatalSchemaValidationResultChunk {
670
705
  const termination = createSchemaValidationTermination(toolCall, validation);
671
- const hint =
672
- termination.repairHint
673
- || "Use write for full-file replacement, or provide path, old_string, and new_string for edit.";
706
+ const fallbackHint =
707
+ "Use write for full-file replacement, or provide path, old_string, and new_string for edit.";
708
+ const suffix = termination.repairHint ? "" : ` ${fallbackHint}`;
674
709
  const content =
675
- `Skipped malformed tool call "${toolCall.function.name}": ${termination.message} ${hint}`.trim();
710
+ `Skipped malformed tool call "${toolCall.function.name}": ${termination.message}${suffix}`.trim();
676
711
  return {
677
712
  id: meta.id,
678
713
  object: "chat.completion.chunk",