Allow processes to access the Roo Code API via a unix socket (#2232)

This commit is contained in:
Chris Estreich 2025-04-02 12:42:00 -07:00 committed by GitHub
parent 0409509002
commit a73fce9027
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2619 additions and 330 deletions

1
.tool-versions Normal file
View file

@ -0,0 +1 @@
nodejs v20.18.1

View file

@ -156,7 +156,7 @@ while (Date.now() - startTime < timeout) {
6. **Grading**: When grading tests, use the `Grade:` format to ensure the test is graded correctly (See modes.test.ts for an example).
```typescript
await globalThis.api.startNewTask(
`Given this prompt: ${testPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ${output} \n Be sure to say 'I AM DONE GRADING' after the task is complete`,
)
await globalThis.api.startNewTask({
text: `Given this prompt: ${testPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ${output} \n Be sure to say 'I AM DONE GRADING' after the task is complete`,
})
```

View file

@ -3,13 +3,12 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build": "cd .. && npm run compile && npm run build:webview",
"compile": "rm -rf out && tsc -p tsconfig.json",
"lint": "eslint src --ext ts",
"check-types": "tsc --noEmit",
"test": "npm run compile && npx dotenvx run -f .env.local -- node ./out/runTest.js",
"ci": "npm run build && npm run test",
"clean": "rimraf out"
"test": "npm run build && npx dotenvx run -f .env.local -- node ./out/runTest.js",
"ci": "npm run vscode-test && npm run test",
"build": "rimraf out && tsc -p tsconfig.json",
"vscode-test": "cd .. && npm run vscode-test"
},
"dependencies": {},
"devDependencies": {

View file

@ -2,12 +2,6 @@ import * as assert from "assert"
import * as vscode from "vscode"
suite("Roo Code Extension", () => {
test("OPENROUTER_API_KEY environment variable is set", () => {
if (!process.env.OPENROUTER_API_KEY) {
assert.fail("OPENROUTER_API_KEY environment variable is not set")
}
})
test("Commands should be registered", async () => {
const expectedCommands = [
"roo-cline.plusButtonClicked",

View file

@ -3,9 +3,9 @@ import Mocha from "mocha"
import { glob } from "glob"
import * as vscode from "vscode"
import { RooCodeAPI } from "../../../src/exports/roo-code"
import type { RooCodeAPI } from "../../../src/exports/roo-code"
import { waitUntilReady } from "./utils"
import { waitFor } from "./utils"
declare global {
var api: RooCodeAPI
@ -18,18 +18,25 @@ export async function run() {
throw new Error("Extension not found")
}
// Activate the extension if it's not already active.
const api = extension.isActive ? extension.exports : await extension.activate()
// TODO: We might want to support a "free" model out of the box so
// contributors can run the tests locally without having to pay.
await api.setConfiguration({
apiProvider: "openrouter",
apiProvider: "openrouter" as const,
openRouterApiKey: process.env.OPENROUTER_API_KEY!,
openRouterModelId: "anthropic/claude-3.5-sonnet",
openRouterModelId: "google/gemini-2.0-flash-001",
openRouterModelInfo: {
maxTokens: 8192,
contextWindow: 1000000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.1,
outputPrice: 0.4,
thinking: false,
},
})
await waitUntilReady({ api })
await vscode.commands.executeCommand("roo-cline.SidebarProvider.focus")
await waitFor(() => api.isReady())
// Expose the API to the tests.
globalThis.api = api

View file

@ -1,9 +1,11 @@
import * as assert from "assert"
import { getCompletion, getMessage, sleep, waitForCompletion, waitUntilAborted } from "./utils"
import type { ClineMessage } from "../../../src/exports/roo-code"
import { waitUntilCompleted } from "./utils"
suite("Roo Code Modes", () => {
test("Should handle switching modes correctly", async function () {
test("Should handle switching modes correctly", async () => {
const api = globalThis.api
/**
@ -14,30 +16,44 @@ suite("Roo Code Modes", () => {
"For each mode (Code, Architect, Ask) respond with the mode name and what it specializes in after switching to that mode. " +
"Do not start with the current mode."
await api.setConfiguration({ mode: "Code", alwaysAllowModeSwitch: true, autoApprovalEnabled: true })
const switchModesTaskId = await api.startNewTask(switchModesPrompt)
await waitForCompletion({ api, taskId: switchModesTaskId, timeout: 60_000 })
let messages: ClineMessage[] = []
api.on("message", ({ message }) => {
if (message.type === "say" && message.partial === false) {
messages.push(message)
}
})
const switchModesTaskId = await api.startNewTask({
configuration: { mode: "Code", alwaysAllowModeSwitch: true, autoApprovalEnabled: true },
text: switchModesPrompt,
})
await waitUntilCompleted({ api, taskId: switchModesTaskId, timeout: 60_000 })
/**
* Grade the response.
*/
const gradePrompt =
`Given this prompt: ${switchModesPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ` +
api
.getMessages(switchModesTaskId)
.filter(({ type }) => type === "say")
.map(({ text }) => text ?? "")
.join("\n")
const response = messages
.filter(({ type, say, partial }) => say === "text")
.map(({ text }) => text ?? "")
.join("\n")
await api.setConfiguration({ mode: "Ask" })
const gradeTaskId = await api.startNewTask(gradePrompt)
await waitForCompletion({ api, taskId: gradeTaskId, timeout: 60_000 })
const gradePrompt = `Given this prompt: ${switchModesPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)". For example: Grade 7\n\nResponse: ${response}`
const completion = getCompletion({ api, taskId: gradeTaskId })
messages = []
const gradeTaskId = await api.startNewTask({ configuration: { mode: "Ask" }, text: gradePrompt })
await waitUntilCompleted({ api, taskId: gradeTaskId })
const completion = messages.find(({ type, say, partial }) => say === "completion_result")
const match = completion?.text?.match(/Grade: (\d+)/)
const score = parseInt(match?.[1] ?? "0")
assert.ok(score >= 7 && score <= 10, `Grade must be between 7 and 10 - ${completion?.text}`)
assert.ok(
score >= 7 && score <= 10,
`Grade must be between 7 and 10. DEBUG: score = ${score}, completion = ${completion?.text}`,
)
await api.cancelCurrentTask()
})

View file

@ -1,13 +1,24 @@
import * as assert from "assert"
import { sleep, waitFor, getMessage, waitForCompletion } from "./utils"
import type { ClineMessage } from "../../../src/exports/roo-code"
import { sleep, waitFor, waitUntilCompleted } from "./utils"
suite("Roo Code Subtasks", () => {
test("Should handle subtask cancellation and resumption correctly", async function () {
test("Should handle subtask cancellation and resumption correctly", async () => {
const api = globalThis.api
const messages: Record<string, ClineMessage[]> = {}
api.on("message", ({ taskId, message }) => {
if (message.type === "say" && message.partial === false) {
messages[taskId] = messages[taskId] || []
messages[taskId].push(message)
}
})
await api.setConfiguration({
mode: "Code",
mode: "ask",
alwaysAllowModeSwitch: true,
alwaysAllowSubtasks: true,
autoApprovalEnabled: true,
@ -17,18 +28,19 @@ suite("Roo Code Subtasks", () => {
const childPrompt = "You are a calculator. Respond only with numbers. What is the square root of 9?"
// Start a parent task that will create a subtask.
const parentTaskId = await api.startNewTask(
"You are the parent task. " +
const parentTaskId = await api.startNewTask({
text:
"You are the parent task. " +
`Create a subtask by using the new_task tool with the message '${childPrompt}'.` +
"After creating the subtask, wait for it to complete and then respond 'Parent task resumed'.",
)
})
let spawnedTaskId: string | undefined = undefined
// Wait for the subtask to be spawned and then cancel it.
api.on("taskSpawned", (_, childTaskId) => (spawnedTaskId = childTaskId))
await waitFor(() => !!spawnedTaskId)
await sleep(2_000) // Give the task a chance to start and populate the history.
await sleep(1_000) // Give the task a chance to start and populate the history.
await api.cancelCurrentTask()
// Wait a bit to ensure any task resumption would have happened.
@ -37,35 +49,27 @@ suite("Roo Code Subtasks", () => {
// The parent task should not have resumed yet, so we shouldn't see
// "Parent task resumed".
assert.ok(
getMessage({
api,
taskId: parentTaskId,
include: "Parent task resumed",
exclude: "You are the parent task",
}) === undefined,
messages[parentTaskId].find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
undefined,
"Parent task should not have resumed after subtask cancellation",
)
// Start a new task with the same message as the subtask.
const anotherTaskId = await api.startNewTask(childPrompt)
await waitForCompletion({ api, taskId: anotherTaskId })
const anotherTaskId = await api.startNewTask({ text: childPrompt })
await waitUntilCompleted({ api, taskId: anotherTaskId })
// Wait a bit to ensure any task resumption would have happened.
await sleep(2_000)
// The parent task should still not have resumed.
assert.ok(
getMessage({
api,
taskId: parentTaskId,
include: "Parent task resumed",
exclude: "You are the parent task",
}) === undefined,
messages[parentTaskId].find(({ type, text }) => type === "say" && text === "Parent task resumed") ===
undefined,
"Parent task should not have resumed after subtask cancellation",
)
// Clean up - cancel all tasks.
await api.clearCurrentTask()
await waitForCompletion({ api, taskId: parentTaskId })
await waitUntilCompleted({ api, taskId: parentTaskId })
})
})

View file

@ -1,10 +1,33 @@
import { waitForMessage } from "./utils"
import * as assert from "assert"
import type { ClineMessage } from "../../../src/exports/roo-code"
import { waitUntilCompleted } from "./utils"
suite("Roo Code Task", () => {
test("Should handle prompt and response correctly", async function () {
test("Should handle prompt and response correctly", async () => {
const api = globalThis.api
await api.setConfiguration({ mode: "Ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true })
const taskId = await api.startNewTask("Hello world, what is your name? Respond with 'My name is ...'")
await waitForMessage({ api, taskId, include: "My name is Roo" })
const messages: ClineMessage[] = []
api.on("message", ({ message }) => {
if (message.type === "say" && message.partial === false) {
messages.push(message)
}
})
const taskId = await api.startNewTask({
configuration: { mode: "Ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true },
text: "Hello world, what is your name? Respond with 'My name is ...'",
})
await waitUntilCompleted({ api, taskId })
const completion = messages.find(({ say, partial }) => say === "completion_result")
assert.ok(
completion?.text?.includes("My name is Roo"),
`Completion should include "My name is Roo" - ${completion?.text}`,
)
})
})

View file

@ -1,6 +1,6 @@
import * as vscode from "vscode"
import { RooCodeAPI } from "../../../src/exports/roo-code"
import type { RooCodeAPI } from "../../../src/exports/roo-code"
type WaitForOptions = {
timeout?: number
@ -9,7 +9,7 @@ type WaitForOptions = {
export const waitFor = (
condition: (() => Promise<boolean>) | (() => boolean),
{ timeout = 60_000, interval = 250 }: WaitForOptions = {},
{ timeout = 30_000, interval = 250 }: WaitForOptions = {},
) => {
let timeoutId: NodeJS.Timeout | undefined = undefined
@ -41,15 +41,6 @@ export const waitFor = (
])
}
type WaitUntilReadyOptions = WaitForOptions & {
api: RooCodeAPI
}
export const waitUntilReady = async ({ api, ...options }: WaitUntilReadyOptions) => {
await vscode.commands.executeCommand("roo-cline.SidebarProvider.focus")
await waitFor(() => api.isReady(), options)
}
type WaitUntilAbortedOptions = WaitForOptions & {
api: RooCodeAPI
taskId: string
@ -61,39 +52,15 @@ export const waitUntilAborted = async ({ api, taskId, ...options }: WaitUntilAbo
await waitFor(() => set.has(taskId), options)
}
export const waitForCompletion = async ({
api,
taskId,
...options
}: WaitUntilReadyOptions & {
taskId: string
}) => waitFor(() => !!getCompletion({ api, taskId }), options)
export const getCompletion = ({ api, taskId }: { api: RooCodeAPI; taskId: string }) =>
api.getMessages(taskId).find(({ say, partial }) => say === "completion_result" && partial === false)
type WaitForMessageOptions = WaitUntilReadyOptions & {
taskId: string
include: string
exclude?: string
}
export const waitForMessage = async ({ api, taskId, include, exclude, ...options }: WaitForMessageOptions) =>
waitFor(() => !!getMessage({ api, taskId, include, exclude }), options)
type GetMessageOptions = {
type WaitUntilCompletedOptions = WaitForOptions & {
api: RooCodeAPI
taskId: string
include: string
exclude?: string
}
export const getMessage = ({ api, taskId, include, exclude }: GetMessageOptions) =>
api
.getMessages(taskId)
.find(
({ type, text }) =>
type === "say" && text && text.includes(include) && (!exclude || !text.includes(exclude)),
)
export const waitUntilCompleted = async ({ api, taskId, ...options }: WaitUntilCompletedOptions) => {
const set = new Set<string>()
api.on("taskCompleted", (taskId) => set.add(taskId))
await waitFor(() => set.has(taskId), options)
}
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View file

@ -17,6 +17,7 @@
"benchmark/**",
"src/activate/**",
"src/exports/**",
"src/schemas/ipc.ts",
"src/extension.ts",
"scripts/**"
],

1782
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -312,23 +312,19 @@
"build:webview": "cd webview-ui && npm run build",
"build:esbuild": "node esbuild.js --production",
"compile": "tsc -p . --outDir out && node esbuild.js",
"install:all": "npm install npm-run-all && npm run install:_all",
"install:_all": "npm-run-all -p install-*",
"install:all": "npm install npm-run-all && npm-run-all -l -p install-*",
"install-extension": "npm install",
"install-webview": "cd webview-ui && npm install",
"install-e2e": "cd e2e && npm install",
"install-benchmark": "cd benchmark && npm install",
"lint": "npm-run-all -p lint:*",
"lint": "npm-run-all -l -p lint:*",
"lint:extension": "eslint src --ext ts",
"lint:webview": "cd webview-ui && npm run lint",
"lint:e2e": "cd e2e && npm run lint",
"lint:benchmark": "cd benchmark && npm run lint",
"check-types": "npm-run-all -p check-types:*",
"check-types": "npm-run-all -l -p check-types:*",
"check-types:extension": "tsc --noEmit",
"check-types:webview": "cd webview-ui && npm run check-types",
"check-types:e2e": "cd e2e && npm run check-types",
"check-types:benchmark": "cd benchmark && npm run check-types",
"package": "npm-run-all -p build:webview build:esbuild check-types lint",
"package": "npm-run-all -l -p build:webview build:esbuild check-types lint",
"pretest": "npm run compile",
"dev": "cd webview-ui && npm run dev",
"test": "node scripts/run-tests.js",
@ -340,17 +336,20 @@
"version-packages": "changeset version && npm install --package-lock-only",
"vscode:prepublish": "npm run package",
"vsix": "rimraf bin && mkdirp bin && npx vsce package --out bin",
"watch": "npm-run-all -p watch:*",
"watch": "npm-run-all -l -p watch:*",
"watch:esbuild": "node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"watch-tests": "tsc -p . -w --outDir out",
"changeset": "changeset",
"knip": "knip --include files",
"clean": "npm-run-all -p clean:*",
"clean": "npm-run-all -l -p clean:*",
"clean:extension": "rimraf bin dist out",
"clean:webview": "cd webview-ui && npm run clean",
"clean:e2e": "cd e2e && npm run clean",
"clean:benchmark": "cd benchmark && npm run clean",
"vscode-test": "npm-run-all -l -p vscode-test:*",
"vscode-test:extension": "tsc -p . --outDir out && node esbuild.js",
"vscode-test:webview": "cd webview-ui && npm run build",
"update-contributors": "node scripts/update-contributors.js",
"generate-types": "tsx scripts/generate-types.mts"
},
@ -388,6 +387,7 @@
"js-tiktoken": "^1.0.19",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"node-ipc": "^12.0.0",
"openai": "^4.78.1",
"os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
@ -421,9 +421,11 @@
"@types/glob": "^8.1.0",
"@types/jest": "^29.5.14",
"@types/node": "20.x",
"@types/node-ipc": "^9.2.3",
"@types/string-similarity": "^4.0.2",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.11.0",
"@vscode/vsce": "^3.3.2",
"esbuild": "^0.24.0",
"eslint": "^8.57.0",
"execa": "^9.5.2",

View file

@ -95,7 +95,7 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
}
}
const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
// (This example uses webviewProvider activation event which is necessary to
// deserialize cached webview, but since we use retainContextWhenHidden, we
// don't need to use that event).
@ -139,4 +139,6 @@ const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterComman
// Lock the editor group so clicking on files doesn't open them over the panel.
await delay(100)
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
return tabProvider
}

View file

@ -2,105 +2,201 @@ import { EventEmitter } from "events"
import * as vscode from "vscode"
import { ClineProvider } from "../core/webview/ClineProvider"
import { openClineInNewTab } from "../activate/registerCommands"
import { RooCodeAPI, RooCodeEvents, TokenUsage, RooCodeSettings } from "./roo-code"
import { MessageHistory } from "./message-history"
import { RooCodeSettings, RooCodeEvents, RooCodeEventName, ClineMessage } from "../schemas"
import { IpcOrigin, IpcMessageType, TaskCommandName, TaskEvent } from "../schemas/ipc"
import { RooCodeAPI } from "./interface"
import { IpcServer } from "./ipc"
import { outputChannelLog } from "./log"
export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
private readonly outputChannel: vscode.OutputChannel
private readonly provider: ClineProvider
private readonly history: MessageHistory
private readonly tokenUsage: Record<string, TokenUsage>
private readonly sidebarProvider: ClineProvider
private tabProvider?: ClineProvider
private readonly context: vscode.ExtensionContext
private readonly ipc?: IpcServer
private readonly taskMap = new Map<string, ClineProvider>()
private readonly log: (...args: unknown[]) => void
constructor(outputChannel: vscode.OutputChannel, provider: ClineProvider) {
constructor(
outputChannel: vscode.OutputChannel,
provider: ClineProvider,
socketPath?: string,
enableLogging = false,
) {
super()
this.outputChannel = outputChannel
this.provider = provider
this.history = new MessageHistory()
this.tokenUsage = {}
this.sidebarProvider = provider
this.context = provider.context
this.provider.on("clineCreated", (cline) => {
cline.on("message", (message) => this.emit("message", { taskId: cline.taskId, ...message }))
cline.on("taskStarted", () => this.emit("taskStarted", cline.taskId))
cline.on("taskPaused", () => this.emit("taskPaused", cline.taskId))
cline.on("taskUnpaused", () => this.emit("taskUnpaused", cline.taskId))
cline.on("taskAskResponded", () => this.emit("taskAskResponded", cline.taskId))
cline.on("taskAborted", () => this.emit("taskAborted", cline.taskId))
cline.on("taskSpawned", (childTaskId) => this.emit("taskSpawned", cline.taskId, childTaskId))
cline.on("taskCompleted", (_, usage) => this.emit("taskCompleted", cline.taskId, usage))
cline.on("taskTokenUsageUpdated", (_, usage) => this.emit("taskTokenUsageUpdated", cline.taskId, usage))
this.emit("taskCreated", cline.taskId)
})
this.log = enableLogging
? (...args: unknown[]) => {
outputChannelLog(this.outputChannel, ...args)
console.log(args)
}
: () => {}
this.on("message", ({ taskId, action, message }) => {
if (action === "created") {
this.history.add(taskId, message)
} else if (action === "updated") {
this.history.update(taskId, message)
}
})
this.registerListeners(this.sidebarProvider)
this.on("taskTokenUsageUpdated", (taskId, usage) => (this.tokenUsage[taskId] = usage))
if (socketPath) {
const ipc = (this.ipc = new IpcServer(socketPath, this.log))
ipc.listen()
this.log(`[API] ipc server started: socketPath=${socketPath}, pid=${process.pid}, ppid=${process.ppid}`)
ipc.on(IpcMessageType.TaskCommand, async (_clientId, { commandName, data }) => {
switch (commandName) {
case TaskCommandName.StartNewTask:
this.log(`[API] StartNewTask -> ${data.text}, ${JSON.stringify(data.configuration)}`)
await this.startNewTask(data)
break
case TaskCommandName.CancelTask:
this.log(`[API] CancelTask -> ${data}`)
await this.cancelTask(data)
break
case TaskCommandName.CloseTask:
this.log(`[API] CloseTask -> ${data}`)
await vscode.commands.executeCommand("workbench.action.files.saveFiles")
await vscode.commands.executeCommand("workbench.action.closeWindow")
break
}
})
}
}
public async startNewTask(text?: string, images?: string[]) {
await this.provider.removeClineFromStack()
await this.provider.postStateToWebview()
await this.provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
await this.provider.postMessageToWebview({ type: "invoke", invoke: "newChat", text, images })
public override emit<K extends keyof RooCodeEvents>(
eventName: K,
...args: K extends keyof RooCodeEvents ? RooCodeEvents[K] : never
) {
const data = { eventName: eventName as RooCodeEventName, payload: args } as TaskEvent
this.ipc?.broadcast({ type: IpcMessageType.TaskEvent, origin: IpcOrigin.Server, data })
return super.emit(eventName, ...args)
}
const cline = await this.provider.initClineWithTask(text, images)
return cline.taskId
public async startNewTask({
configuration,
text,
images,
newTab,
}: {
configuration: RooCodeSettings
text?: string
images?: string[]
newTab?: boolean
}) {
let provider: ClineProvider
if (newTab) {
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
if (!this.tabProvider) {
this.tabProvider = await openClineInNewTab({ context: this.context, outputChannel: this.outputChannel })
this.registerListeners(this.tabProvider)
}
provider = this.tabProvider
} else {
provider = this.sidebarProvider
}
if (configuration) {
await provider.setValues(configuration)
if (configuration.allowedCommands) {
await vscode.workspace
.getConfiguration("roo-cline")
.update("allowedCommands", configuration.allowedCommands, vscode.ConfigurationTarget.Global)
}
}
await provider.removeClineFromStack()
await provider.postStateToWebview()
await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" })
await provider.postMessageToWebview({ type: "invoke", invoke: "newChat", text, images })
const { taskId } = await provider.initClineWithTask(text, images)
return taskId
}
public getCurrentTaskStack() {
return this.provider.getCurrentTaskStack()
return this.sidebarProvider.getCurrentTaskStack()
}
public async clearCurrentTask(lastMessage?: string) {
await this.provider.finishSubTask(lastMessage)
await this.provider.postStateToWebview()
await this.sidebarProvider.finishSubTask(lastMessage)
await this.sidebarProvider.postStateToWebview()
}
public async cancelCurrentTask() {
await this.provider.cancelTask()
await this.sidebarProvider.cancelTask()
}
public async cancelTask(taskId: string) {
const provider = this.taskMap.get(taskId)
if (provider) {
await provider.cancelTask()
this.taskMap.delete(taskId)
}
}
public async sendMessage(text?: string, images?: string[]) {
await this.provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images })
await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images })
}
public async pressPrimaryButton() {
await this.provider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" })
await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" })
}
public async pressSecondaryButton() {
await this.provider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" })
await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" })
}
public getConfiguration() {
return this.provider.getValues()
return this.sidebarProvider.getValues()
}
public async setConfiguration(values: RooCodeSettings) {
await this.provider.setValues(values)
await this.provider.postStateToWebview()
await this.sidebarProvider.setValues(values)
await this.sidebarProvider.postStateToWebview()
}
public isReady() {
return this.provider.viewLaunched
return this.sidebarProvider.viewLaunched
}
public getMessages(taskId: string) {
return this.history.getMessages(taskId)
}
private registerListeners(provider: ClineProvider) {
provider.on("clineCreated", (cline) => {
cline.on("taskStarted", () => {
this.emit(RooCodeEventName.TaskStarted, cline.taskId)
this.taskMap.set(cline.taskId, provider)
})
public getTokenUsage(taskId: string) {
return this.tokenUsage[taskId]
}
cline.on("message", (message) => this.emit(RooCodeEventName.Message, { taskId: cline.taskId, ...message }))
public log(message: string) {
this.outputChannel.appendLine(message)
cline.on("taskTokenUsageUpdated", (_, usage) =>
this.emit(RooCodeEventName.TaskTokenUsageUpdated, cline.taskId, usage),
)
cline.on("taskAskResponded", () => this.emit(RooCodeEventName.TaskAskResponded, cline.taskId))
cline.on("taskAborted", () => {
this.emit(RooCodeEventName.TaskAborted, cline.taskId)
this.taskMap.delete(cline.taskId)
})
cline.on("taskCompleted", (_, usage) => {
this.emit(RooCodeEventName.TaskCompleted, cline.taskId, usage)
this.taskMap.delete(cline.taskId)
})
cline.on("taskSpawned", (childTaskId) => this.emit(RooCodeEventName.TaskSpawned, cline.taskId, childTaskId))
cline.on("taskPaused", () => this.emit(RooCodeEventName.TaskPaused, cline.taskId))
cline.on("taskUnpaused", () => this.emit(RooCodeEventName.TaskUnpaused, cline.taskId))
this.emit(RooCodeEventName.TaskCreated, cline.taskId)
})
}
}

View file

@ -1,24 +1,13 @@
import { EventEmitter } from "events"
import type { ProviderSettings, GlobalSettings, ClineMessage, TokenUsage } from "./types"
import type { ProviderSettings, GlobalSettings, ClineMessage, TokenUsage, RooCodeEvents } from "./types"
export type { RooCodeSettings, ProviderSettings, GlobalSettings, ClineMessage, TokenUsage, RooCodeEvents }
import { RooCodeEventName } from "../schemas"
export type { RooCodeEventName }
type RooCodeSettings = GlobalSettings & ProviderSettings
export type { RooCodeSettings, ProviderSettings, GlobalSettings, ClineMessage, TokenUsage }
export interface RooCodeEvents {
message: [{ taskId: string; action: "created" | "updated"; message: ClineMessage }]
taskCreated: [taskId: string]
taskStarted: [taskId: string]
taskPaused: [taskId: string]
taskUnpaused: [taskId: string]
taskAskResponded: [taskId: string]
taskAborted: [taskId: string]
taskSpawned: [taskId: string, childTaskId: string]
taskCompleted: [taskId: string, usage: TokenUsage]
taskTokenUsageUpdated: [taskId: string, usage: TokenUsage]
}
export interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
/**
* Starts a new task with an optional initial message and images.
@ -26,7 +15,17 @@ export interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
* @param images Optional array of image data URIs (e.g., "data:image/webp;base64,...").
* @returns The ID of the new task.
*/
startNewTask(task?: string, images?: string[]): Promise<string>
startNewTask({
configuration,
text,
images,
newTab,
}: {
configuration?: RooCodeSettings
text?: string
images?: string[]
newTab?: boolean
}): Promise<string>
/**
* Returns the current task stack.
@ -77,24 +76,4 @@ export interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
* Returns true if the API is ready to use.
*/
isReady(): boolean
/**
* Returns the messages for a given task.
* @param taskId The ID of the task.
* @returns An array of ClineMessage objects.
*/
getMessages(taskId: string): ClineMessage[]
/**
* Returns the token usage for a given task.
* @param taskId The ID of the task.
* @returns A TokenUsage object.
*/
getTokenUsage(taskId: string): TokenUsage
/**
* Logs a message to the output channel.
* @param message The message to log.
*/
log(message: string): void
}

138
src/exports/ipc.ts Normal file
View file

@ -0,0 +1,138 @@
import EventEmitter from "node:events"
import { Socket } from "node:net"
import * as crypto from "node:crypto"
import ipc from "node-ipc"
import { IpcOrigin, IpcMessageType, IpcMessage, ipcMessageSchema, TaskCommand, TaskEvent } from "../schemas/ipc"
/**
* IpcServer
*/
type IpcServerEvents = {
[IpcMessageType.Connect]: [clientId: string]
[IpcMessageType.Disconnect]: [clientId: string]
[IpcMessageType.TaskCommand]: [clientId: string, data: TaskCommand]
[IpcMessageType.TaskEvent]: [relayClientId: string | undefined, data: TaskEvent]
}
export class IpcServer extends EventEmitter<IpcServerEvents> {
private readonly _socketPath: string
private readonly _log: (...args: unknown[]) => void
private readonly _clients: Map<string, Socket>
private _isListening = false
constructor(socketPath: string, log = console.log) {
super()
this._socketPath = socketPath
this._log = log
this._clients = new Map()
}
public listen() {
this._isListening = true
ipc.config.silent = true
ipc.serve(this.socketPath, () => {
ipc.server.on("connect", (socket) => this.onConnect(socket))
ipc.server.on("socket.disconnected", (socket) => this.onDisconnect(socket))
ipc.server.on("message", (data) => this.onMessage(data))
})
ipc.server.start()
}
private onConnect(socket: Socket) {
const clientId = crypto.randomBytes(6).toString("hex")
this._clients.set(clientId, socket)
this.log(`[server#onConnect] clientId = ${clientId}, # clients = ${this._clients.size}`)
this.send(socket, {
type: IpcMessageType.Ack,
origin: IpcOrigin.Server,
data: { clientId, pid: process.pid, ppid: process.ppid },
})
this.emit(IpcMessageType.Connect, clientId)
}
private onDisconnect(destroyedSocket: Socket) {
let disconnectedClientId: string | undefined
for (const [clientId, socket] of this._clients.entries()) {
if (socket === destroyedSocket) {
disconnectedClientId = clientId
this._clients.delete(clientId)
break
}
}
this.log(`[server#socket.disconnected] clientId = ${disconnectedClientId}, # clients = ${this._clients.size}`)
if (disconnectedClientId) {
this.emit(IpcMessageType.Disconnect, disconnectedClientId)
}
}
private onMessage(data: unknown) {
if (typeof data !== "object") {
this.log("[server#onMessage] invalid data", data)
return
}
const result = ipcMessageSchema.safeParse(data)
if (!result.success) {
this.log("[server#onMessage] invalid payload", result.error.format(), data)
return
}
const payload = result.data
if (payload.origin === IpcOrigin.Client) {
switch (payload.type) {
case IpcMessageType.TaskCommand:
this.emit(IpcMessageType.TaskCommand, payload.clientId, payload.data)
break
default:
this.log(`[server#onMessage] unhandled payload: ${JSON.stringify(payload)}`)
break
}
}
}
private log(...args: unknown[]) {
this._log(...args)
}
public broadcast(message: IpcMessage) {
// this.log("[server#broadcast] message =", message)
ipc.server.broadcast("message", message)
}
public send(client: string | Socket, message: IpcMessage) {
// this.log("[server#send] message =", message)
if (typeof client === "string") {
const socket = this._clients.get(client)
if (socket) {
ipc.server.emit(socket, "message", message)
}
} else {
ipc.server.emit(client, "message", message)
}
}
public get socketPath() {
return this._socketPath
}
public get isListening() {
return this._isListening
}
}

32
src/exports/log.ts Normal file
View file

@ -0,0 +1,32 @@
import * as vscode from "vscode"
export function outputChannelLog(outputChannel: vscode.OutputChannel, ...args: unknown[]) {
for (const arg of args) {
if (arg === null) {
outputChannel.appendLine("null")
} else if (arg === undefined) {
outputChannel.appendLine("undefined")
} else if (typeof arg === "string") {
outputChannel.appendLine(arg)
} else if (arg instanceof Error) {
outputChannel.appendLine(`Error: ${arg.message}\n${arg.stack || ""}`)
} else {
try {
outputChannel.appendLine(
JSON.stringify(
arg,
(key, value) => {
if (typeof value === "bigint") return `BigInt(${value})`
if (typeof value === "function") return `Function: ${value.name || "anonymous"}`
if (typeof value === "symbol") return value.toString()
return value
},
2,
),
)
} catch (error) {
outputChannel.appendLine(`[Non-serializable object: ${Object.prototype.toString.call(arg)}]`)
}
}
}
}

View file

@ -1,35 +0,0 @@
import { ClineMessage } from "./roo-code"
export class MessageHistory {
private readonly messages: Record<string, Record<number, ClineMessage>>
private readonly list: Record<string, number[]>
constructor() {
this.messages = {}
this.list = {}
}
public add(taskId: string, message: ClineMessage) {
if (!this.messages[taskId]) {
this.messages[taskId] = {}
}
this.messages[taskId][message.ts] = message
if (!this.list[taskId]) {
this.list[taskId] = []
}
this.list[taskId].push(message.ts)
}
public update(taskId: string, message: ClineMessage) {
if (this.messages[taskId][message.ts]) {
this.messages[taskId][message.ts] = message
}
}
public getMessages(taskId: string) {
return (this.list[taskId] ?? []).map((ts) => this.messages[taskId][ts]).filter(Boolean)
}
}

View file

@ -397,26 +397,125 @@ type TokenUsage = {
contextTokens: number
}
type RooCodeSettings = GlobalSettings & ProviderSettings
interface RooCodeEvents {
type RooCodeEvents = {
message: [
{
taskId: string
action: "created" | "updated"
message: ClineMessage
message: {
ts: number
type: "ask" | "say"
ask?:
| (
| "followup"
| "command"
| "command_output"
| "completion_result"
| "tool"
| "api_req_failed"
| "resume_task"
| "resume_completed_task"
| "mistake_limit_reached"
| "browser_action_launch"
| "use_mcp_server"
| "finishTask"
)
| undefined
say?:
| (
| "task"
| "error"
| "api_req_started"
| "api_req_finished"
| "api_req_retried"
| "api_req_retry_delayed"
| "api_req_deleted"
| "text"
| "reasoning"
| "completion_result"
| "user_feedback"
| "user_feedback_diff"
| "command_output"
| "tool"
| "shell_integration_warning"
| "browser_action"
| "browser_action_result"
| "command"
| "mcp_server_request_started"
| "mcp_server_response"
| "new_task_started"
| "new_task"
| "checkpoint_saved"
| "rooignore_error"
)
| undefined
text?: string | undefined
images?: string[] | undefined
partial?: boolean | undefined
reasoning?: string | undefined
conversationHistoryIndex?: number | undefined
checkpoint?:
| {
[x: string]: unknown
}
| undefined
progressStatus?:
| {
icon?: string | undefined
text?: string | undefined
}
| undefined
}
},
]
taskCreated: [string]
taskStarted: [string]
taskPaused: [string]
taskUnpaused: [string]
taskAskResponded: [string]
taskAborted: [string]
taskSpawned: [string, string]
taskCompleted: [
string,
{
totalTokensIn: number
totalTokensOut: number
totalCacheWrites?: number | undefined
totalCacheReads?: number | undefined
totalCost: number
contextTokens: number
},
]
taskTokenUsageUpdated: [
string,
{
totalTokensIn: number
totalTokensOut: number
totalCacheWrites?: number | undefined
totalCacheReads?: number | undefined
totalCost: number
contextTokens: number
},
]
taskCreated: [taskId: string]
taskStarted: [taskId: string]
taskPaused: [taskId: string]
taskUnpaused: [taskId: string]
taskAskResponded: [taskId: string]
taskAborted: [taskId: string]
taskSpawned: [taskId: string, childTaskId: string]
taskCompleted: [taskId: string, usage: TokenUsage]
taskTokenUsageUpdated: [taskId: string, usage: TokenUsage]
}
/**
* RooCodeEvent
*/
declare enum RooCodeEventName {
Message = "message",
TaskCreated = "taskCreated",
TaskStarted = "taskStarted",
TaskPaused = "taskPaused",
TaskUnpaused = "taskUnpaused",
TaskAskResponded = "taskAskResponded",
TaskAborted = "taskAborted",
TaskSpawned = "taskSpawned",
TaskCompleted = "taskCompleted",
TaskTokenUsageUpdated = "taskTokenUsageUpdated",
}
type RooCodeSettings = GlobalSettings & ProviderSettings
interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
/**
* Starts a new task with an optional initial message and images.
@ -424,7 +523,17 @@ interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
* @param images Optional array of image data URIs (e.g., "data:image/webp;base64,...").
* @returns The ID of the new task.
*/
startNewTask(task?: string, images?: string[]): Promise<string>
startNewTask({
configuration,
text,
images,
newTab,
}: {
configuration?: RooCodeSettings
text?: string
images?: string[]
newTab?: boolean
}): Promise<string>
/**
* Returns the current task stack.
* @returns An array of task IDs.
@ -466,23 +575,15 @@ interface RooCodeAPI extends EventEmitter<RooCodeEvents> {
* Returns true if the API is ready to use.
*/
isReady(): boolean
/**
* Returns the messages for a given task.
* @param taskId The ID of the task.
* @returns An array of ClineMessage objects.
*/
getMessages(taskId: string): ClineMessage[]
/**
* Returns the token usage for a given task.
* @param taskId The ID of the task.
* @returns A TokenUsage object.
*/
getTokenUsage(taskId: string): TokenUsage
/**
* Logs a message to the output channel.
* @param message The message to log.
*/
log(message: string): void
}
export type { ClineMessage, GlobalSettings, ProviderSettings, RooCodeAPI, RooCodeEvents, RooCodeSettings, TokenUsage }
export {
type ClineMessage,
type GlobalSettings,
type ProviderSettings,
type RooCodeAPI,
RooCodeEventName,
type RooCodeEvents,
type RooCodeSettings,
type TokenUsage,
}

View file

@ -405,3 +405,107 @@ type TokenUsage = {
}
export type { TokenUsage }
type RooCodeEvents = {
message: [
{
taskId: string
action: "created" | "updated"
message: {
ts: number
type: "ask" | "say"
ask?:
| (
| "followup"
| "command"
| "command_output"
| "completion_result"
| "tool"
| "api_req_failed"
| "resume_task"
| "resume_completed_task"
| "mistake_limit_reached"
| "browser_action_launch"
| "use_mcp_server"
| "finishTask"
)
| undefined
say?:
| (
| "task"
| "error"
| "api_req_started"
| "api_req_finished"
| "api_req_retried"
| "api_req_retry_delayed"
| "api_req_deleted"
| "text"
| "reasoning"
| "completion_result"
| "user_feedback"
| "user_feedback_diff"
| "command_output"
| "tool"
| "shell_integration_warning"
| "browser_action"
| "browser_action_result"
| "command"
| "mcp_server_request_started"
| "mcp_server_response"
| "new_task_started"
| "new_task"
| "checkpoint_saved"
| "rooignore_error"
)
| undefined
text?: string | undefined
images?: string[] | undefined
partial?: boolean | undefined
reasoning?: string | undefined
conversationHistoryIndex?: number | undefined
checkpoint?:
| {
[x: string]: unknown
}
| undefined
progressStatus?:
| {
icon?: string | undefined
text?: string | undefined
}
| undefined
}
},
]
taskCreated: [string]
taskStarted: [string]
taskPaused: [string]
taskUnpaused: [string]
taskAskResponded: [string]
taskAborted: [string]
taskSpawned: [string, string]
taskCompleted: [
string,
{
totalTokensIn: number
totalTokensOut: number
totalCacheWrites?: number | undefined
totalCacheReads?: number | undefined
totalCost: number
contextTokens: number
},
]
taskTokenUsageUpdated: [
string,
{
totalTokensIn: number
totalTokensOut: number
totalCacheWrites?: number | undefined
totalCacheReads?: number | undefined
totalCost: number
contextTokens: number
},
]
}
export type { RooCodeEvents }

View file

@ -119,7 +119,9 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.commands.executeCommand("roo-cline.activationCompleted")
// Implements the `RooCodeAPI` interface.
return new API(outputChannel, provider)
const socketPath = process.env.ROO_CODE_IPC_SOCKET_PATH
const enableLogging = typeof socketPath === "string"
return new API(outputChannel, provider, socketPath, enableLogging)
}
// This method is called when your extension is deactivated

View file

@ -186,7 +186,7 @@ export class Terminal {
console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`)
process.emit(
"no_shell_integration",
"Shell integration initialization sequence '\\x1b]633;A' was not received within 4 seconds. Shell integration has been disabled for this terminal instance. Increase the timeout in the settings if necessary.",
`Shell integration initialization sequence '\\x1b]633;A' was not received within ${Terminal.shellIntegrationTimeout / 1000}s. Shell integration has been disabled for this terminal instance. Increase the timeout in the settings if necessary.`,
)
})
})

View file

@ -626,6 +626,8 @@ export const GLOBAL_SETTINGS_KEYS = Object.keys(globalSettingsRecord) as Keys<Gl
* RooCodeSettings
*/
export const rooCodeSettingsSchema = providerSettingsSchema.merge(globalSettingsSchema)
export type RooCodeSettings = GlobalSettings & ProviderSettings
/**
@ -787,6 +789,44 @@ export const tokenUsageSchema = z.object({
export type TokenUsage = z.infer<typeof tokenUsageSchema>
/**
* RooCodeEvent
*/
export enum RooCodeEventName {
Message = "message",
TaskCreated = "taskCreated",
TaskStarted = "taskStarted",
TaskPaused = "taskPaused",
TaskUnpaused = "taskUnpaused",
TaskAskResponded = "taskAskResponded",
TaskAborted = "taskAborted",
TaskSpawned = "taskSpawned",
TaskCompleted = "taskCompleted",
TaskTokenUsageUpdated = "taskTokenUsageUpdated",
}
export const rooCodeEventsSchema = z.object({
[RooCodeEventName.Message]: z.tuple([
z.object({
taskId: z.string(),
action: z.union([z.literal("created"), z.literal("updated")]),
message: clineMessageSchema,
}),
]),
[RooCodeEventName.TaskCreated]: z.tuple([z.string()]),
[RooCodeEventName.TaskStarted]: z.tuple([z.string()]),
[RooCodeEventName.TaskPaused]: z.tuple([z.string()]),
[RooCodeEventName.TaskUnpaused]: z.tuple([z.string()]),
[RooCodeEventName.TaskAskResponded]: z.tuple([z.string()]),
[RooCodeEventName.TaskAborted]: z.tuple([z.string()]),
[RooCodeEventName.TaskSpawned]: z.tuple([z.string(), z.string()]),
[RooCodeEventName.TaskCompleted]: z.tuple([z.string(), tokenUsageSchema]),
[RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema]),
})
export type RooCodeEvents = z.infer<typeof rooCodeEventsSchema>
/**
* TypeDefinition
*/
@ -801,6 +841,7 @@ export const typeDefinitions: TypeDefinition[] = [
{ schema: globalSettingsSchema, identifier: "GlobalSettings" },
{ schema: clineMessageSchema, identifier: "ClineMessage" },
{ schema: tokenUsageSchema, identifier: "TokenUsage" },
{ schema: rooCodeEventsSchema, identifier: "RooCodeEvents" },
]
// Also export as default for ESM compatibility

135
src/schemas/ipc.ts Normal file
View file

@ -0,0 +1,135 @@
import { z } from "zod"
import { RooCodeEventName, rooCodeEventsSchema, rooCodeSettingsSchema } from "./index"
/**
* Ack
*/
export const ackSchema = z.object({
clientId: z.string(),
pid: z.number(),
ppid: z.number(),
})
export type Ack = z.infer<typeof ackSchema>
/**
* TaskCommand
*/
export enum TaskCommandName {
StartNewTask = "StartNewTask",
CancelTask = "CancelTask",
CloseTask = "CloseTask",
}
export const taskCommandSchema = z.discriminatedUnion("commandName", [
z.object({
commandName: z.literal(TaskCommandName.StartNewTask),
data: z.object({
configuration: rooCodeSettingsSchema,
text: z.string(),
images: z.array(z.string()).optional(),
newTab: z.boolean().optional(),
}),
}),
z.object({
commandName: z.literal(TaskCommandName.CancelTask),
data: z.string(),
}),
z.object({
commandName: z.literal(TaskCommandName.CloseTask),
data: z.string(),
}),
])
export type TaskCommand = z.infer<typeof taskCommandSchema>
/**
* TaskEvent
*/
export const taskEventSchema = z.discriminatedUnion("eventName", [
z.object({
eventName: z.literal(RooCodeEventName.Message),
payload: rooCodeEventsSchema.shape[RooCodeEventName.Message],
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskCreated),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskCreated],
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskStarted),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskStarted],
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskPaused),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskPaused],
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskUnpaused),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskUnpaused],
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskAskResponded),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskAskResponded],
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskAborted),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskAborted],
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskSpawned),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskSpawned],
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskCompleted),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskCompleted],
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskTokenUsageUpdated),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskTokenUsageUpdated],
}),
])
export type TaskEvent = z.infer<typeof taskEventSchema>
/**
* IpcMessage
*/
export enum IpcMessageType {
Connect = "Connect",
Disconnect = "Disconnect",
Ack = "Ack",
TaskCommand = "TaskCommand",
TaskEvent = "TaskEvent",
}
export enum IpcOrigin {
Client = "client",
Server = "server",
}
export const ipcMessageSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal(IpcMessageType.Ack),
origin: z.literal(IpcOrigin.Server),
data: ackSchema,
}),
z.object({
type: z.literal(IpcMessageType.TaskCommand),
origin: z.literal(IpcOrigin.Client),
clientId: z.string(),
data: taskCommandSchema,
}),
z.object({
type: z.literal(IpcMessageType.TaskEvent),
origin: z.literal(IpcOrigin.Server),
relayClientId: z.string().optional(),
data: taskEventSchema,
}),
])
export type IpcMessage = z.infer<typeof ipcMessageSchema>