Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions core/llm/llms/OpenRouter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ChatCompletionCreateParams } from "openai/resources/index";

import { OPENROUTER_HEADERS } from "@continuedev/openai-adapters";

import { LLMOptions } from "../../index.js";
import { osModelsEditPrompt } from "../templates/edit.js";

Expand All @@ -18,6 +20,19 @@ class OpenRouter extends OpenAI {
useLegacyCompletionsEndpoint: false,
};

constructor(options: LLMOptions) {
super({
...options,
requestOptions: {
...options.requestOptions,
headers: {
...OPENROUTER_HEADERS,
...options.requestOptions?.headers,
},
},
});
}

private isAnthropicModel(model?: string): boolean {
if (!model) return false;
const modelLower = model.toLowerCase();
Expand Down
18 changes: 2 additions & 16 deletions extensions/vscode/src/VsCodeIde.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { Repository } from "./otherExtensions/git";
import { SecretStorage } from "./stubs/SecretStorage";
import { VsCodeIdeUtils } from "./util/ideUtils";
import { runCommandInTerminal } from "./util/runCommandInTerminal";
import { getExtensionVersion, isExtensionPrerelease } from "./util/util";
import { getExtensionUri, openEditorAndRevealRange } from "./util/vscode";
import { VsCodeWebviewProtocol } from "./webviewProtocol";
Expand Down Expand Up @@ -154,7 +155,7 @@
case "error":
return showErrorMessage(message, "Show logs").then((selection) => {
if (selection === "Show logs") {
vscode.commands.executeCommand("workbench.action.toggleDevTools");

Check warning on line 158 in extensions/vscode/src/VsCodeIde.ts

View workflow job for this annotation

GitHub Actions / vscode-checks

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
}
});
case "info":
Expand Down Expand Up @@ -322,7 +323,7 @@
new vscode.Position(startLine, 0),
new vscode.Position(endLine, 0),
);
openEditorAndRevealRange(vscode.Uri.parse(fileUri), range).then(

Check warning on line 326 in extensions/vscode/src/VsCodeIde.ts

View workflow job for this annotation

GitHub Actions / vscode-checks

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
(editor) => {
// Select the lines
editor.selection = new vscode.Selection(
Expand All @@ -337,22 +338,7 @@
command: string,
options: TerminalOptions = { reuseTerminal: true },
): Promise<void> {
let terminal: vscode.Terminal | undefined;
if (vscode.window.terminals.length && options.reuseTerminal) {
if (options.terminalName) {
terminal = vscode.window.terminals.find(
(t) => t?.name === options.terminalName,
);
} else {
terminal = vscode.window.activeTerminal ?? vscode.window.terminals[0];
}
}

if (!terminal) {
terminal = vscode.window.createTerminal(options?.terminalName);
}
terminal.show();
terminal.sendText(command, false);
await runCommandInTerminal(command, options);
}

async saveFile(fileUri: string): Promise<void> {
Expand Down
118 changes: 118 additions & 0 deletions extensions/vscode/src/util/runCommandInTerminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { TerminalOptions } from "core";
import * as vscode from "vscode";

const REMOTE_TERMINAL_TIMEOUT_MS = 5000;

const terminalCacheByName = new Map<string, vscode.Terminal>();

function getReusableTerminal(
options: TerminalOptions,
): vscode.Terminal | undefined {
if (!vscode.window.terminals.length || !options.reuseTerminal) {
return undefined;
}

if (options.terminalName) {
const cachedTerminal = terminalCacheByName.get(options.terminalName);
if (cachedTerminal && vscode.window.terminals.includes(cachedTerminal)) {
return cachedTerminal;
}

terminalCacheByName.delete(options.terminalName);
return vscode.window.terminals.find(
(terminal) => terminal?.name === options.terminalName,
);
}

return vscode.window.activeTerminal ?? vscode.window.terminals[0];
}

async function createTerminal(
options: TerminalOptions,
): Promise<vscode.Terminal> {
if (!vscode.env.remoteName) {
return vscode.window.createTerminal(options.terminalName);
}

const existingTerminals = new Set(vscode.window.terminals);

return await new Promise<vscode.Terminal>((resolve, reject) => {
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;

const cleanup = () => {
terminalListener.dispose();
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
};

const resolveIfNewTerminalExists = () => {
const newTerminal = vscode.window.terminals.find(
(terminal) => !existingTerminals.has(terminal),
);
if (!newTerminal) {
return false;
}

settled = true;
cleanup();
resolve(newTerminal);
return true;
};

const terminalListener = vscode.window.onDidOpenTerminal((terminal) => {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Remote terminal creation is race-prone and can resolve to an unrelated concurrently opened terminal, causing commands to run in the wrong terminal session.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At extensions/vscode/src/util/runCommandInTerminal.ts, line 64:

<comment>Remote terminal creation is race-prone and can resolve to an unrelated concurrently opened terminal, causing commands to run in the wrong terminal session.</comment>

<file context>
@@ -0,0 +1,118 @@
+      return true;
+    };
+
+    const terminalListener = vscode.window.onDidOpenTerminal((terminal) => {
+      if (settled || existingTerminals.has(terminal)) {
+        return;
</file context>
Fix with Cubic

if (settled || existingTerminals.has(terminal)) {
return;
}

settled = true;
cleanup();
resolve(terminal);
});

timeoutHandle = setTimeout(() => {
if (settled) {
return;
}

settled = true;
cleanup();
reject(new Error("Timed out waiting for remote terminal to open"));
}, REMOTE_TERMINAL_TIMEOUT_MS);

if (resolveIfNewTerminalExists()) {
return;
Comment on lines +84 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove pre-open scan for "new" remote terminals

The early resolveIfNewTerminalExists() check can select an unrelated terminal that was opened by the user/another extension after existingTerminals is captured but before workbench.action.terminal.new is executed. In that race, this function returns immediately, skips creating its own terminal, and then sends the command to the wrong terminal, which makes /cmd and startup helpers non-deterministic in busy remote workspaces.

Useful? React with 👍 / 👎.

}

void vscode.commands.executeCommand("workbench.action.terminal.new").then(
() => {
resolveIfNewTerminalExists();
},
(error: unknown) => {
if (settled) {
return;
}

settled = true;
cleanup();
reject(error);
},
);
});
}

export async function runCommandInTerminal(
command: string,
options: TerminalOptions = { reuseTerminal: true },
): Promise<void> {
const terminal =
getReusableTerminal(options) ?? (await createTerminal(options));

if (options.terminalName) {
terminalCacheByName.set(options.terminalName, terminal);
}

terminal.show();
terminal.sendText(command, true);
}
159 changes: 159 additions & 0 deletions extensions/vscode/src/util/runCommandInTerminal.vitest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

type MockTerminal = {
name: string;
show: ReturnType<typeof vi.fn>;
sendText: ReturnType<typeof vi.fn>;
};

const terminalListeners = new Set<(terminal: MockTerminal) => void>();
const terminals: MockTerminal[] = [];

const windowMock = {
terminals,
activeTerminal: undefined as MockTerminal | undefined,
createTerminal: vi.fn<(name?: string) => MockTerminal>(),
onDidOpenTerminal: vi.fn((listener: (terminal: MockTerminal) => void) => {
terminalListeners.add(listener);
return {
dispose: vi.fn(() => terminalListeners.delete(listener)),
};
}),
};

const commandsMock = {
executeCommand: vi.fn<(command: string) => Promise<void>>(),
};

const envMock = {
remoteName: undefined as string | undefined,
};

vi.mock("vscode", () => ({
window: windowMock,
commands: commandsMock,
env: envMock,
}));

function createTerminal(name: string): MockTerminal {
return {
name,
show: vi.fn(),
sendText: vi.fn(),
};
}

describe("runCommandInTerminal", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();

terminals.length = 0;
terminalListeners.clear();
windowMock.activeTerminal = undefined;
envMock.remoteName = undefined;

windowMock.createTerminal.mockImplementation((name?: string) => {
const terminal = createTerminal(
name ?? "Terminal " + (terminals.length + 1),
);
terminals.push(terminal);
return terminal;
});

commandsMock.executeCommand.mockResolvedValue();
});

it("reuses the active terminal and sends an executing command", async () => {
const terminal = createTerminal("Active");
terminals.push(terminal);
windowMock.activeTerminal = terminal;

const { runCommandInTerminal } = await import("./runCommandInTerminal");

await runCommandInTerminal("echo hello");

expect(windowMock.createTerminal).not.toHaveBeenCalled();
expect(commandsMock.executeCommand).not.toHaveBeenCalled();
expect(terminal.show).toHaveBeenCalledOnce();
expect(terminal.sendText).toHaveBeenCalledWith("echo hello", true);
});

it("creates a named local terminal when no reusable terminal exists", async () => {
const { runCommandInTerminal } = await import("./runCommandInTerminal");

await runCommandInTerminal("npm test", {
reuseTerminal: true,
terminalName: "Start Ollama",
});

expect(windowMock.createTerminal).toHaveBeenCalledWith("Start Ollama");
expect(commandsMock.executeCommand).not.toHaveBeenCalled();

const createdTerminal = terminals[0];
expect(createdTerminal.show).toHaveBeenCalledOnce();
expect(createdTerminal.sendText).toHaveBeenCalledWith("npm test", true);
});

it("creates remote terminals through the remote-aware VS Code command", async () => {
envMock.remoteName = "ssh-remote";
commandsMock.executeCommand.mockImplementation(async (command: string) => {
expect(command).toBe("workbench.action.terminal.new");
const terminal = createTerminal("Remote Shell");
terminals.push(terminal);
for (const listener of terminalListeners) {
listener(terminal);
}
});

const { runCommandInTerminal } = await import("./runCommandInTerminal");

await runCommandInTerminal("pwd", { reuseTerminal: true });

expect(windowMock.createTerminal).not.toHaveBeenCalled();
expect(commandsMock.executeCommand).toHaveBeenCalledWith(
"workbench.action.terminal.new",
);

const createdTerminal = terminals[0];
expect(createdTerminal.show).toHaveBeenCalledOnce();
expect(createdTerminal.sendText).toHaveBeenCalledWith("pwd", true);
});

it("reuses cached remote terminals for named commands", async () => {
envMock.remoteName = "dev-container";

let createdCount = 0;
commandsMock.executeCommand.mockImplementation(async () => {
createdCount += 1;
const terminal = createTerminal("Remote " + createdCount);
terminals.push(terminal);
for (const listener of terminalListeners) {
listener(terminal);
}
});

const { runCommandInTerminal } = await import("./runCommandInTerminal");

await runCommandInTerminal("ollama serve", {
reuseTerminal: true,
terminalName: "Start Ollama",
});
await runCommandInTerminal("ollama serve", {
reuseTerminal: true,
terminalName: "Start Ollama",
});

expect(commandsMock.executeCommand).toHaveBeenCalledTimes(1);
expect(terminals[0].sendText).toHaveBeenNthCalledWith(
1,
"ollama serve",
true,
);
expect(terminals[0].sendText).toHaveBeenNthCalledWith(
2,
"ollama serve",
true,
);
});
});
5 changes: 3 additions & 2 deletions packages/openai-adapters/src/apis/OpenRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ export interface OpenRouterConfig extends OpenAIConfig {

// TODO: Extract detailed error info from OpenRouter's error.metadata.raw to surface better messages

const OPENROUTER_HEADERS: Record<string, string> = {
export const OPENROUTER_HEADERS: Record<string, string> = {
"HTTP-Referer": "https://www.continue.dev/",
"X-Title": "Continue",
"X-OpenRouter-Title": "Continue",
"X-OpenRouter-Categories": "ide-extension",
};

export class OpenRouterApi extends OpenAIApi {
Expand Down
1 change: 1 addition & 0 deletions packages/openai-adapters/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,4 +243,5 @@ export {
} from "./apis/AnthropicUtils.js";

export { isResponsesModel } from "./apis/openaiResponses.js";
export { OPENROUTER_HEADERS } from "./apis/OpenRouter.js";
export { extractBase64FromDataUrl, parseDataUrl } from "./util/url.js";
Loading