From 6a0451147c405d8dadd22f9619c42e0abf2ec5a6 Mon Sep 17 00:00:00 2001 From: Naved Date: Sun, 21 Jun 2026 15:36:12 -0700 Subject: [PATCH 1/5] feat(remote-bridge): Phase 1 forked node bridge talking to IPC API surface (#650) Add packages/remote-bridge, a standalone forked Node process that connects to the Zoo Code extension's IPC server (the API surface) over a Unix socket using the same IpcClient the CLI uses. Phase 1 scope (issue #650): - Bridge class wraps IpcClient: connect(), sendCommand(), onEvent(), request() (send a TaskCommand and await the matching TaskEvent response), plus getModes()/getCommands()/getModels()/sendMessage() helpers. - main.ts is the forked process entry point (CLI) that connects to a socket (defaulting to ROO_CODE_IPC_SOCKET_PATH) and runs a single API call, pretty-printing the response. - Integration test stands up a real IpcServer with a mock command handler (mirroring src/extension/api.ts) and proves the round-trip: connect -> Ack -> GetModes -> ModesResponse, plus event forwarding and request timeout. - scripts/demo.ts forks main.ts as a real child process against a mock server to demonstrate the live API call end-to-end. Verified: check-types, lint, and 3/3 vitest tests pass; live demo prints the modesResponse payload from a forked child process. Later phases will forward this IPC traffic over a WebRTC data channel. --- knip.json | 2 +- packages/remote-bridge/README.md | 80 +++++++++ packages/remote-bridge/eslint.config.mjs | 4 + packages/remote-bridge/package.json | 29 +++ packages/remote-bridge/scripts/demo.ts | 88 +++++++++ .../src/__tests__/bridge.test.ts | 161 +++++++++++++++++ packages/remote-bridge/src/bridge.ts | 170 ++++++++++++++++++ packages/remote-bridge/src/index.ts | 1 + packages/remote-bridge/src/main.ts | 131 ++++++++++++++ packages/remote-bridge/tsconfig.json | 5 + packages/remote-bridge/vitest.config.ts | 11 ++ pnpm-lock.yaml | 31 ++++ 12 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 packages/remote-bridge/README.md create mode 100644 packages/remote-bridge/eslint.config.mjs create mode 100644 packages/remote-bridge/package.json create mode 100644 packages/remote-bridge/scripts/demo.ts create mode 100644 packages/remote-bridge/src/__tests__/bridge.test.ts create mode 100644 packages/remote-bridge/src/bridge.ts create mode 100644 packages/remote-bridge/src/index.ts create mode 100644 packages/remote-bridge/src/main.ts create mode 100644 packages/remote-bridge/tsconfig.json create mode 100644 packages/remote-bridge/vitest.config.ts diff --git a/knip.json b/knip.json index 9037fa042d..e497d1a8a1 100644 --- a/knip.json +++ b/knip.json @@ -68,7 +68,7 @@ "project": ["src/**/*.ts"], "ignoreDependencies": ["@types/vscode"] }, - "packages/{build,ipc,types}": { + "packages/{build,ipc,types,remote-bridge}": { "project": ["src/**/*.ts"] } }, diff --git a/packages/remote-bridge/README.md b/packages/remote-bridge/README.md new file mode 100644 index 0000000000..9247d572ff --- /dev/null +++ b/packages/remote-bridge/README.md @@ -0,0 +1,80 @@ +# Remote Bridge + +A forked Node.js process that connects to the Zoo Code extension's IPC **API surface** over a Unix socket. This is **Phase 1** of the Remote Control & Approval feature ([issue #650](https://github.com/Zoo-Code-Org/Zoo-Code/issues/650)). + +## What Phase 1 does + +- Spawns as a standalone Node process (forked from the extension in later phases). +- Connects to the extension's [`IpcServer`](../ipc/src/ipc-server.ts) using the same [`IpcClient`](../ipc/src/ipc-client.ts) the CLI uses. +- Can issue any [`TaskCommand`](../../packages/types/src/ipc.ts) (e.g. `GetModes`, `GetCommands`, `SendMessage`) and receive the resulting [`TaskEvent`](../../packages/types/src/events.ts) responses. +- Proves the round-trip works via an integration test that stands up a real `IpcServer` and runs an API call through the `Bridge`. + +What it does **not** do yet (later phases): WebRTC data channel, signaling, remote UI, push notifications. + +## Architecture + +``` +┌──────────────────────┐ Unix socket ┌──────────────────────┐ +│ Zoo Code Extension │ ◄──────────────► │ Bridge Process │ +│ (IpcServer) │ /tmp/...sock │ (IpcClient + Bridge)│ +│ - Task engine │ │ - send commands │ +│ - Approval flow │ │ - receive events │ +└──────────────────────┘ └──────────────────────┘ +``` + +The socket path is the same one the extension already reads: the `ROO_CODE_IPC_SOCKET_PATH` environment variable. The extension only starts its IPC server when that variable is set, so the bridge is opt-in. + +## Usage + +### As a library + +```typescript +import { Bridge } from "@roo-code/remote-bridge" + +const bridge = new Bridge("/tmp/zoo-code.sock") +await bridge.connect() + +// Issue an API call and await the response event. +const modes = await bridge.getModes() +console.log(modes.payload[0]) + +// Subscribe to live task events. +bridge.onEvent("taskStarted", (event) => console.log("started", event.payload[0])) + +// Send a message to the active task. +bridge.sendMessage("hello from the bridge") + +bridge.disconnect() +``` + +### As a CLI (demo / smoke test) + +```bash +# 1. Start the extension with the IPC server enabled. +ROO_CODE_IPC_SOCKET_PATH=/tmp/zoo-code.sock code . + +# 2. From the repo, run the bridge against that socket. +pnpm --filter @roo-code/remote-bridge start -- --socket /tmp/zoo-code.sock --command get-modes +``` + +The response event is pretty-printed to stdout; diagnostic logs go to stderr. + +## Scripts + +| Script | Description | +| ------------------ | ---------------------------------------------------------------- | +| `pnpm test` | Run the vitest integration tests (stands up a real `IpcServer`). | +| `pnpm check-types` | `tsc --noEmit`. | +| `pnpm lint` | ESLint. | +| `pnpm start` | Run the CLI entry point via `tsx`. | + +## Tests + +The integration test in [`src/__tests__/bridge.test.ts`](src/__tests__/bridge.test.ts) creates a real `IpcServer` on a temporary socket, wires a command handler that mimics the extension's API (see [`src/extension/api.ts`](../../src/extension/api.ts)), and verifies that a `Bridge` can: + +1. Connect and receive its `Ack`. +2. Issue `GetModes` and receive the `ModesResponse` event. +3. Forward broadcast `TaskEvent`s to `onEvent` subscribers (and unsubscribe correctly). +4. Reject `request()` on timeout when no response arrives. + +This is the proof that the forked process can talk to the API surface. diff --git a/packages/remote-bridge/eslint.config.mjs b/packages/remote-bridge/eslint.config.mjs new file mode 100644 index 0000000000..19270d6b09 --- /dev/null +++ b/packages/remote-bridge/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@roo-code/config-eslint/base" + +/** @type {import("eslint").Linter.Config} */ +export default [...config] \ No newline at end of file diff --git a/packages/remote-bridge/package.json b/packages/remote-bridge/package.json new file mode 100644 index 0000000000..e67be56997 --- /dev/null +++ b/packages/remote-bridge/package.json @@ -0,0 +1,29 @@ +{ + "name": "@roo-code/remote-bridge", + "description": "Forked Node bridge process that connects Zoo Code's IPC API surface to remote clients (WebRTC in later phases).", + "version": "0.0.1", + "type": "module", + "exports": "./src/index.ts", + "bin": "./src/main.ts", + "scripts": { + "lint": "eslint src scripts --ext=ts --max-warnings=0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "start": "tsx src/main.ts", + "demo": "tsx scripts/demo.ts", + "clean": "rimraf .turbo" + }, + "dependencies": { + "@roo-code/ipc": "workspace:^", + "@roo-code/types": "workspace:^", + "node-ipc": "^12.0.0" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "20.19.43", + "@types/node-ipc": "9.2.3", + "tsx": "4.19.4", + "vitest": "4.1.0" + } +} diff --git a/packages/remote-bridge/scripts/demo.ts b/packages/remote-bridge/scripts/demo.ts new file mode 100644 index 0000000000..1a1783ac81 --- /dev/null +++ b/packages/remote-bridge/scripts/demo.ts @@ -0,0 +1,88 @@ +/** + * Live end-to-end demo for Phase 1. + * + * Spins up a mock IPC server (mimicking the extension's API surface) and then + * forks the bridge process (`src/main.ts`) as a real child Node process + * pointed at that socket. The bridge issues a `GetModes` API call and prints + * the response. This proves the forked node process can talk to the API + * surface over a real Unix socket. + * + * Run with: pnpm --filter @roo-code/remote-bridge exec tsx scripts/demo.ts + */ +import os from "node:os" +import path from "node:path" +import fs from "node:fs" +import { spawn } from "node:child_process" +import { fileURLToPath } from "node:url" + +import { IpcServer } from "@roo-code/ipc" +import { IpcMessageType, IpcOrigin, RooCodeEventName, TaskCommandName, type TaskEvent } from "@roo-code/types" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function uniqueSocketPath(): string { + return path.join(os.tmpdir(), `zoo-code-bridge-demo-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`) +} + +async function main() { + const socketPath = uniqueSocketPath() + try { + fs.unlinkSync(socketPath) + } catch { + // ignore + } + + const server = new IpcServer(socketPath, (...args) => process.stderr.write(`[mock-server] ${args.join(" ")}\n`)) + + server.on(IpcMessageType.TaskCommand, (clientId, command) => { + process.stderr.write(`[mock-server] received command: ${command.commandName}\n`) + + if (command.commandName === TaskCommandName.GetModes) { + server.send(clientId, { + type: IpcMessageType.TaskEvent, + origin: IpcOrigin.Server, + data: { + eventName: RooCodeEventName.ModesResponse, + payload: [ + [ + { slug: "code", name: "Code" }, + { slug: "architect", name: "Architect" }, + { slug: "ask", name: "Ask" }, + { slug: "debug", name: "Debug" }, + ], + ], + } as TaskEvent, + }) + } + }) + + server.listen() + process.stderr.write(`[demo] mock IPC server listening on ${socketPath}\n`) + + // Fork the bridge entry point as a real child process. + const mainPath = path.join(__dirname, "..", "src", "main.ts") + const child = spawn("tsx", [mainPath, "--socket", socketPath, "--command", "get-modes"], { + stdio: "inherit", + }) + + await new Promise((resolve) => { + child.on("close", (code) => { + process.stderr.write(`\n[demo] bridge process exited with code ${code}\n`) + resolve() + }) + }) + + cleanupSocket(socketPath) + // node-ipc keeps the event loop alive in the mock server; exit explicitly. + process.exit(0) +} + +function cleanupSocket(socketPath: string) { + try { + fs.unlinkSync(socketPath) + } catch { + // ignore + } +} + +void main() diff --git a/packages/remote-bridge/src/__tests__/bridge.test.ts b/packages/remote-bridge/src/__tests__/bridge.test.ts new file mode 100644 index 0000000000..543cbfa04f --- /dev/null +++ b/packages/remote-bridge/src/__tests__/bridge.test.ts @@ -0,0 +1,161 @@ +import os from "node:os" +import path from "node:path" +import fs from "node:fs" + +import { IpcServer } from "@roo-code/ipc" +import { + type TaskCommand, + type TaskEvent, + IpcMessageType, + IpcOrigin, + RooCodeEventName, + TaskCommandName, +} from "@roo-code/types" + +import { Bridge } from "../bridge.js" + +/** + * Phase 1 integration test: prove the forked bridge process can talk to the + * extension's IPC API surface — it can issue a {@link TaskCommand} and receive + * the matching {@link TaskEvent} response. + * + * We stand up a real {@link IpcServer} (the same class the extension uses) and + * wire it to a tiny command handler that mimics the extension's API for the + * query commands. Then a {@link Bridge} connects and runs an API call. + */ + +function uniqueSocketPath(): string { + return path.join(os.tmpdir(), `zoo-code-bridge-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`) +} + +function cleanupSocket(socketPath: string) { + try { + fs.unlinkSync(socketPath) + } catch { + // ignore — file may not exist + } +} + +describe("Bridge (Phase 1: IPC API surface)", () => { + let server: IpcServer + let socketPath: string + + beforeEach(() => { + socketPath = uniqueSocketPath() + cleanupSocket(socketPath) + + server = new IpcServer(socketPath, () => {}) + server.listen() + }) + + afterEach(() => { + try { + server.broadcast({ type: IpcMessageType.TaskEvent, origin: IpcOrigin.Server, data: {} as TaskEvent }) + } catch { + // noop + } + + // node-ipc doesn't expose a clean stop; rely on process teardown + unlink. + cleanupSocket(socketPath) + }) + + it("connects, issues GetModes, and receives the ModesResponse event", async () => { + const expectedModes = [ + { slug: "code", name: "Code" }, + { slug: "architect", name: "Architect" }, + { slug: "ask", name: "Ask" }, + ] + + // Mimic the extension's API command handler (see src/extension/api.ts). + server.on(IpcMessageType.TaskCommand, (clientId, command: TaskCommand) => { + expect(command.commandName).toBe(TaskCommandName.GetModes) + + server.send(clientId, { + type: IpcMessageType.TaskEvent, + origin: IpcOrigin.Server, + data: { + eventName: RooCodeEventName.ModesResponse, + payload: [expectedModes], + } as TaskEvent, + }) + }) + + const bridge = new Bridge(socketPath) + + try { + await bridge.connect() + expect(bridge.isReady).toBe(true) + + const event = await bridge.getModes() + + expect(event.eventName).toBe(RooCodeEventName.ModesResponse) + expect(event.payload[0]).toEqual(expectedModes) + } finally { + bridge.disconnect() + } + }) + + it("forwards arbitrary TaskEvents to onEvent subscribers", async () => { + const bridge = new Bridge(socketPath) + + const received: TaskEvent[] = [] + const off = bridge.onEvent(RooCodeEventName.TaskStarted, (event) => received.push(event)) + + try { + await bridge.connect() + + // Simulate the extension broadcasting a TaskStarted event. + server.broadcast({ + type: IpcMessageType.TaskEvent, + origin: IpcOrigin.Server, + data: { + eventName: RooCodeEventName.TaskStarted, + payload: ["task-abc-123"], + } as TaskEvent, + }) + + // Give the socket a tick to deliver. + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(received).toHaveLength(1) + expect(received[0]!.eventName).toBe(RooCodeEventName.TaskStarted) + expect(received[0]!.payload[0]).toBe("task-abc-123") + + off() + + // After unsubscribing, a second broadcast should not be delivered. + server.broadcast({ + type: IpcMessageType.TaskEvent, + origin: IpcOrigin.Server, + data: { + eventName: RooCodeEventName.TaskStarted, + payload: ["task-xyz-789"], + } as TaskEvent, + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(received).toHaveLength(1) + } finally { + bridge.disconnect() + } + }) + + it("rejects request() when the response event never arrives", async () => { + // Server that swallows GetCommands without responding. + server.on(IpcMessageType.TaskCommand, () => { + // intentionally no response + }) + + const bridge = new Bridge(socketPath) + + try { + await bridge.connect() + + await expect( + bridge.request({ commandName: TaskCommandName.GetCommands }, RooCodeEventName.CommandsResponse, 500), + ).rejects.toThrow(/timed out/) + } finally { + bridge.disconnect() + } + }) +}) diff --git a/packages/remote-bridge/src/bridge.ts b/packages/remote-bridge/src/bridge.ts new file mode 100644 index 0000000000..b6bbf8819f --- /dev/null +++ b/packages/remote-bridge/src/bridge.ts @@ -0,0 +1,170 @@ +import { IpcClient } from "@roo-code/ipc" +import { type TaskCommand, type TaskEvent, IpcMessageType, RooCodeEventName, TaskCommandName } from "@roo-code/types" + +/** + * Bridge is the forked Node process's handle on the Zoo Code API surface. + * + * It owns an {@link IpcClient} that connects to the extension's IPC server over + * a Unix socket (the same socket the CLI uses). In Phase 1 the bridge simply + * proves it can talk to the API surface: it can send {@link TaskCommand}s and + * receive {@link TaskEvent}s. Later phases will forward these over a WebRTC + * data channel to a remote browser/mobile client. + * + * The bridge is intentionally transport-agnostic about the "remote" side — + * everything here is about the local IPC contract with the extension. + */ +export class Bridge { + private readonly _client: IpcClient + private readonly _log: (...args: unknown[]) => void + private readonly _eventHandlers: Map void>> + + constructor(socketPath: string, log: (...args: unknown[]) => void = () => {}) { + this._log = log + this._eventHandlers = new Map() + + this._client = new IpcClient(socketPath, this._log) + + this._client.on(IpcMessageType.Connect, () => this._log("[bridge] ipc connected")) + this._client.on(IpcMessageType.Disconnect, () => this._log("[bridge] ipc disconnected")) + + this._client.on(IpcMessageType.Ack, (ack) => { + this._log(`[bridge] ipc ack clientId=${ack.clientId} pid=${ack.pid}`) + }) + + this._client.on(IpcMessageType.TaskEvent, (event) => this.dispatchTaskEvent(event)) + } + + /** + * Resolves once the IPC client has connected AND received its Ack (i.e. it + * has a clientId and can send commands). Rejects on timeout. + */ + public connect(timeoutMs = 10_000): Promise { + return new Promise((resolve, reject) => { + if (this._client.isReady) { + resolve() + return + } + + const onAck = () => { + clearTimeout(timer) + this._client.off(IpcMessageType.Ack, onAck) + this._client.off(IpcMessageType.Disconnect, onDisconnect) + resolve() + } + + const onDisconnect = () => { + clearTimeout(timer) + this._client.off(IpcMessageType.Ack, onAck) + this._client.off(IpcMessageType.Disconnect, onDisconnect) + reject(new Error("[bridge] ipc disconnected before ack")) + } + + const timer = setTimeout(() => { + this._client.off(IpcMessageType.Ack, onAck) + this._client.off(IpcMessageType.Disconnect, onDisconnect) + reject(new Error(`[bridge] ipc connect timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + this._client.on(IpcMessageType.Ack, onAck) + this._client.on(IpcMessageType.Disconnect, onDisconnect) + }) + } + + /** + * Send a raw {@link TaskCommand} to the extension's API surface. + */ + public sendCommand(command: TaskCommand): void { + this._client.sendCommand(command) + } + + /** + * Subscribe to a {@link TaskEvent} by name. Returns an unsubscribe fn. + */ + public onEvent(eventName: RooCodeEventName, handler: (event: TaskEvent) => void): () => void { + let set = this._eventHandlers.get(eventName) + + if (!set) { + set = new Set() + this._eventHandlers.set(eventName, set) + } + + set.add(handler) + + return () => { + set?.delete(handler) + } + } + + /** + * Send a query command and await the matching response event. This is the + * core "API call" primitive the bridge uses to talk to the extension. + */ + public request( + command: TaskCommand, + responseEventName: T["eventName"], + timeoutMs = 10_000, + ): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + off() + reject(new Error(`[bridge] request timed out waiting for ${responseEventName}`)) + }, timeoutMs) + + const off = this.onEvent(responseEventName, (event) => { + clearTimeout(timer) + off() + resolve(event as T) + }) + + this.sendCommand(command) + }) + } + + /** Convenience: ask the extension for its configured modes. */ + public getModes(): Promise { + return this.request({ commandName: TaskCommandName.GetModes }, RooCodeEventName.ModesResponse) + } + + /** Convenience: ask the extension for its available slash commands. */ + public getCommands(): Promise { + return this.request({ commandName: TaskCommandName.GetCommands }, RooCodeEventName.CommandsResponse) + } + + /** Convenience: ask the extension for its available models. */ + public getModels(): Promise { + return this.request({ commandName: TaskCommandName.GetModels }, RooCodeEventName.ModelsResponse) + } + + /** Send a free-text message to the active task. */ + public sendMessage(text?: string, images?: string[]): void { + this._client.sendTaskMessage(text, images) + } + + public disconnect(): void { + this._client.disconnect() + } + + public get isReady(): boolean { + return this._client.isReady + } + + public get socketPath(): string { + return this._client.socketPath + } + + private dispatchTaskEvent(event: TaskEvent): void { + this._log(`[bridge] event ${event.eventName}`) + + const handlers = this._eventHandlers.get(event.eventName) + + if (handlers) { + for (const handler of handlers) { + try { + handler(event) + } catch (error) { + this._log(`[bridge] event handler for ${event.eventName} threw: ${String(error)}`) + } + } + } + } +} diff --git a/packages/remote-bridge/src/index.ts b/packages/remote-bridge/src/index.ts new file mode 100644 index 0000000000..1f32deb073 --- /dev/null +++ b/packages/remote-bridge/src/index.ts @@ -0,0 +1 @@ +export { Bridge } from "./bridge.js" diff --git a/packages/remote-bridge/src/main.ts b/packages/remote-bridge/src/main.ts new file mode 100644 index 0000000000..6a113059b1 --- /dev/null +++ b/packages/remote-bridge/src/main.ts @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/** + * Remote Bridge — Phase 1 entry point. + * + * This is the forked Node process. It connects to the Zoo Code extension's IPC + * server (the "API surface") over a Unix socket and demonstrates that it can + * issue API calls and receive events. Later phases will forward this traffic + * over a WebRTC data channel to a remote browser/mobile client. + * + * Usage: + * tsx packages/remote-bridge/src/main.ts [--socket /tmp/zoo-code.sock] [--command get-modes|get-commands|get-models] + * + * The socket path defaults to the `ROO_CODE_IPC_SOCKET_PATH` env var, matching + * the variable the extension reads to start its IPC server. + */ +import os from "node:os" +import path from "node:path" + +import { type TaskEvent } from "@roo-code/types" + +import { Bridge } from "./bridge.js" + +interface CliArgs { + socketPath: string + command: "get-modes" | "get-commands" | "get-models" +} + +function defaultSocketPath(): string { + // Mirror the extension's convention. The extension only starts its IPC + // server when ROO_CODE_IPC_SOCKET_PATH is set, so we fall back to a + // sensible per-user default for local dev/demo purposes. + return process.env.ROO_CODE_IPC_SOCKET_PATH ?? path.join(os.tmpdir(), `zoo-code-${os.userInfo().uid}.sock`) +} + +function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = { + socketPath: defaultSocketPath(), + command: "get-modes", + } + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + + if (arg === "--socket" || arg === "-s") { + args.socketPath = argv[++i] ?? args.socketPath + } else if (arg === "--command" || arg === "-c") { + const value = argv[++i] as CliArgs["command"] | undefined + if (!value) continue + + if (value && ["get-modes", "get-commands", "get-models"].includes(value)) { + args.command = value + } + } else if (arg === "--help" || arg === "-h") { + process.stdout.write( + [ + "Usage: remote-bridge [--socket ] [--command get-modes|get-commands|get-models]", + "", + "Connects to the Zoo Code IPC API surface and runs a single API call.", + "", + "Options:", + " --socket, -s Unix socket path (default: $ROO_CODE_IPC_SOCKET_PATH or /tmp/zoo-code-.sock)", + " --command, -c API call to run (default: get-modes)", + " --help, -h Show this help", + ].join("\n") + "\n", + ) + process.exit(0) + } + } + + return args +} + +async function runCommand(bridge: Bridge, command: CliArgs["command"]): Promise { + switch (command) { + case "get-modes": + return bridge.getModes() + case "get-commands": + return bridge.getCommands() + case "get-models": + return bridge.getModels() + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + + const log = (...data: unknown[]) => process.stderr.write(`[remote-bridge] ${data.join(" ")}\n`) + + log(`connecting to IPC socket: ${args.socketPath}`) + + const bridge = new Bridge(args.socketPath, log) + + // Clean up on Ctrl-C / termination so we don't leak the socket client. + const shutdown = (signal: string) => { + log(`received ${signal}, disconnecting`) + bridge.disconnect() + process.exit(0) + } + + process.on("SIGINT", () => shutdown("SIGINT")) + process.on("SIGTERM", () => shutdown("SIGTERM")) + + try { + await bridge.connect() + } catch (error) { + log(`failed to connect: ${error instanceof Error ? error.message : String(error)}`) + log("hint: ensure the extension is running with ROO_CODE_IPC_SOCKET_PATH set to the same path") + process.exit(1) + } + + log(`connected (clientId ready=${bridge.isReady}); running command: ${args.command}`) + + try { + const event = await runCommand(bridge, args.command) + + // Pretty-print the API response to stdout so it's easy to pipe/inspect. + process.stdout.write(JSON.stringify(event, null, 2) + "\n") + + log(`received ${event.eventName} response`) + } catch (error) { + log(`command failed: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } finally { + bridge.disconnect() + // node-ipc keeps the event loop alive; the CLI is a one-shot, so exit + // explicitly once the API call has completed. + process.exit(process.exitCode ?? 0) + } +} + +void main() diff --git a/packages/remote-bridge/tsconfig.json b/packages/remote-bridge/tsconfig.json new file mode 100644 index 0000000000..9840cc4f14 --- /dev/null +++ b/packages/remote-bridge/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@roo-code/config-typescript/base.json", + "include": ["src", "scripts", "*.config.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/remote-bridge/vitest.config.ts b/packages/remote-bridge/vitest.config.ts new file mode 100644 index 0000000000..def71d65c7 --- /dev/null +++ b/packages/remote-bridge/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globals: true, + watch: false, + // Integration tests spin up real Unix sockets; give them a little room. + testTimeout: 15_000, + hookTimeout: 15_000, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4d46a1685..d050873e79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -350,6 +350,37 @@ importers: specifier: 9.2.3 version: 9.2.3 + packages/remote-bridge: + dependencies: + '@roo-code/ipc': + specifier: workspace:^ + version: link:../ipc + '@roo-code/types': + specifier: workspace:^ + version: link:../types + node-ipc: + specifier: ^12.0.0 + version: 12.0.0 + devDependencies: + '@roo-code/config-eslint': + specifier: workspace:^ + version: link:../config-eslint + '@roo-code/config-typescript': + specifier: workspace:^ + version: link:../config-typescript + '@types/node': + specifier: 20.19.43 + version: 20.19.43 + '@types/node-ipc': + specifier: 9.2.3 + version: 9.2.3 + tsx: + specifier: 4.19.4 + version: 4.19.4 + vitest: + specifier: 4.1.0 + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@20.19.43)(@vitest/ui@4.1.0)(jsdom@26.1.0)(vite@8.0.16(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.4.2)(tsx@4.19.4)(yaml@2.9.0)) + packages/telemetry: dependencies: '@roo-code/types': From bed345e997bea52697ddca37770035483dc2aa48 Mon Sep 17 00:00:00 2001 From: Naved Date: Sun, 21 Jun 2026 16:09:28 -0700 Subject: [PATCH 2/5] feat(remote-bridge): enable bridge from Zoo Code preferences + fork from extension (#650) Wire the Phase 1 bridge into the extension so it can be toggled from Zoo Code preferences instead of requiring the ROO_CODE_IPC_SOCKET_PATH env var. Extension changes: - Add zoo-code.remoteControl.enabled and zoo-code.remoteControl.socketPath settings to src/package.json contributes.configuration (+ package.nls.json). - src/extension.ts now starts the IpcServer when the setting is on OR the env var is set, and forks the bridge in --serve mode via RemoteBridgeHost when the setting is on. A config-change listener hot-toggles start/stop without a restart; deactivate() disposes the host. - Add RemoteBridgeHost (src/services/remote-bridge): owns the forked bridge lifecycle with crash-restart backoff, pipes child stdout/stderr to the output channel. Fork impl is injectable for unit tests (7 tests). - Bundle the bridge as a separate esbuild entry to dist/remote-bridge/main.js so the extension can child_process.fork it from the VSIX. Bridge changes: - main.ts gains a long-running --serve mode (the mode the extension forks): connects and streams every TaskEvent to stdout as newline-delimited JSON. One-shot mode is unchanged. - scripts/serve-demo.ts forks the bundled bridge against a mock IPC server and verifies an event streams end-to-end. Verified: src + remote-bridge check-types/lint clean; RemoteBridgeHost 7/7 and bridge 3/3 tests pass; live serve-demo prints the streamed taskStarted ndjson from a forked bundled bridge process. --- packages/remote-bridge/README.md | 29 ++- packages/remote-bridge/package.json | 1 + packages/remote-bridge/scripts/serve-demo.ts | 106 +++++++++ packages/remote-bridge/src/main.ts | 111 +++++++--- src/esbuild.mjs | 24 ++- src/extension.ts | 93 +++++++- src/package.json | 14 ++ src/package.nls.json | 4 +- .../remote-bridge/RemoteBridgeHost.ts | 201 ++++++++++++++++++ .../__tests__/RemoteBridgeHost.spec.ts | 176 +++++++++++++++ 10 files changed, 712 insertions(+), 47 deletions(-) create mode 100644 packages/remote-bridge/scripts/serve-demo.ts create mode 100644 src/services/remote-bridge/RemoteBridgeHost.ts create mode 100644 src/services/remote-bridge/__tests__/RemoteBridgeHost.spec.ts diff --git a/packages/remote-bridge/README.md b/packages/remote-bridge/README.md index 9247d572ff..4bc06d60c5 100644 --- a/packages/remote-bridge/README.md +++ b/packages/remote-bridge/README.md @@ -4,9 +4,10 @@ A forked Node.js process that connects to the Zoo Code extension's IPC **API sur ## What Phase 1 does -- Spawns as a standalone Node process (forked from the extension in later phases). +- Spawns as a standalone Node process, **forked from the extension** when `zoo-code.remoteControl.enabled` is on. - Connects to the extension's [`IpcServer`](../ipc/src/ipc-server.ts) using the same [`IpcClient`](../ipc/src/ipc-client.ts) the CLI uses. - Can issue any [`TaskCommand`](../../packages/types/src/ipc.ts) (e.g. `GetModes`, `GetCommands`, `SendMessage`) and receive the resulting [`TaskEvent`](../../packages/types/src/events.ts) responses. +- In `--serve` mode (the mode the extension forks), stays connected and streams every `TaskEvent` to stdout as newline-delimited JSON. Phase 2 will replace this stdout line with a WebRTC data channel write. - Proves the round-trip works via an integration test that stands up a real `IpcServer` and runs an API call through the `Bridge`. What it does **not** do yet (later phases): WebRTC data channel, signaling, remote UI, push notifications. @@ -22,7 +23,7 @@ What it does **not** do yet (later phases): WebRTC data channel, signaling, remo └──────────────────────┘ └──────────────────────┘ ``` -The socket path is the same one the extension already reads: the `ROO_CODE_IPC_SOCKET_PATH` environment variable. The extension only starts its IPC server when that variable is set, so the bridge is opt-in. +The socket path is the same one the extension already reads: the `ROO_CODE_IPC_SOCKET_PATH` environment variable. The extension starts its IPC server when either `zoo-code.remoteControl.enabled` is on **or** `ROO_CODE_IPC_SOCKET_PATH` is set, so the bridge is opt-in. The bridge process itself is only auto-forked when the setting is on (the env var alone is for headless/CLI use and does not auto-fork). ## Usage @@ -59,14 +60,26 @@ pnpm --filter @roo-code/remote-bridge start -- --socket /tmp/zoo-code.sock --com The response event is pretty-printed to stdout; diagnostic logs go to stderr. +## Enabling from Zoo Code preferences + +Toggle **Settings → Zoo Code → Remote Control: Enabled** (`zoo-code.remoteControl.enabled`). When on, the extension: + +1. Starts its `IpcServer` on the configured socket (`zoo-code.remoteControl.socketPath`, or a per-user default under the system temp dir; `ROO_CODE_IPC_SOCKET_PATH` overrides if set). +2. Forks this bridge in `--serve` mode against that socket via [`RemoteBridgeHost`](../../src/services/remote-bridge/RemoteBridgeHost.ts), with crash-restart backoff. +3. Hot-toggles without a restart via a config-change listener in [`src/extension.ts`](../../src/extension.ts). + +The bridge is bundled into the extension VSIX at `dist/remote-bridge/main.js` by [`src/esbuild.mjs`](../../src/esbuild.mjs). + ## Scripts -| Script | Description | -| ------------------ | ---------------------------------------------------------------- | -| `pnpm test` | Run the vitest integration tests (stands up a real `IpcServer`). | -| `pnpm check-types` | `tsc --noEmit`. | -| `pnpm lint` | ESLint. | -| `pnpm start` | Run the CLI entry point via `tsx`. | +| Script | Description | +| ------------------ | ------------------------------------------------------------------------------------ | +| `pnpm test` | Run the vitest integration tests (stands up a real `IpcServer`). | +| `pnpm check-types` | `tsc --noEmit`. | +| `pnpm lint` | ESLint. | +| `pnpm start` | Run the one-shot CLI entry point via `tsx`. | +| `pnpm demo` | Fork the one-shot bridge against a mock server (live API call). | +| `pnpm demo:serve` | Fork the bundled bridge in `--serve` mode against a mock server and stream an event. | ## Tests diff --git a/packages/remote-bridge/package.json b/packages/remote-bridge/package.json index e67be56997..ad23793117 100644 --- a/packages/remote-bridge/package.json +++ b/packages/remote-bridge/package.json @@ -11,6 +11,7 @@ "test": "vitest run", "start": "tsx src/main.ts", "demo": "tsx scripts/demo.ts", + "demo:serve": "tsx scripts/serve-demo.ts", "clean": "rimraf .turbo" }, "dependencies": { diff --git a/packages/remote-bridge/scripts/serve-demo.ts b/packages/remote-bridge/scripts/serve-demo.ts new file mode 100644 index 0000000000..3ba167ae03 --- /dev/null +++ b/packages/remote-bridge/scripts/serve-demo.ts @@ -0,0 +1,106 @@ +/** + * Live end-to-end demo for the bridge --serve mode (the mode the extension + * forks when `zoo-code.remoteControl.enabled` is on). + * + * Stands up a mock IPC server, forks the bundled bridge + * (`src/dist/remote-bridge/main.js --serve`) as a real child process against + * that socket, broadcasts a TaskEvent, and prints the ndjson line the bridge + * streams to stdout. This proves the extension's fork path works end-to-end. + * + * Run from repo root: + * pnpm --filter @roo-code/remote-bridge exec tsx scripts/serve-demo.ts + */ +import os from "node:os" +import path from "node:path" +import fs from "node:fs" +import { spawn } from "node:child_process" +import { fileURLToPath } from "node:url" + +import { IpcServer } from "@roo-code/ipc" +import { IpcMessageType, IpcOrigin, RooCodeEventName, type TaskEvent } from "@roo-code/types" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function uniqueSocketPath(): string { + return path.join(os.tmpdir(), `zoo-code-serve-demo-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`) +} + +async function main() { + const socketPath = uniqueSocketPath() + try { + fs.unlinkSync(socketPath) + } catch { + // ignore + } + + const server = new IpcServer(socketPath, (...args) => process.stderr.write(`[mock-server] ${args.join(" ")}\n`)) + + // Acknowledge connects; the bridge will log "connected". + server.listen() + process.stderr.write(`[serve-demo] mock IPC server listening on ${socketPath}\n`) + + // Fork the BUNDLED bridge (the same artifact the extension forks). + const bundledMain = path.join(__dirname, "..", "..", "..", "src", "dist", "remote-bridge", "main.js") + + if (!fs.existsSync(bundledMain)) { + process.stderr.write( + `[serve-demo] bundled bridge not found at ${bundledMain}\n` + + " Run `pnpm --filter zoo-code bundle` (or `node src/esbuild.mjs`) first.\n", + ) + process.exit(1) + } + + const child = spawn("node", [bundledMain, "--socket", socketPath, "--serve"], { + stdio: ["ignore", "pipe", "pipe"], + }) + + let gotEvent = false + + child.stdout.on("data", (chunk: Buffer) => { + const line = chunk.toString().trim() + + // The bridge streams TaskEvents as ndjson; pretty-print each line. + if (line.startsWith("{")) { + process.stdout.write(`[serve-demo] bridge streamed: ${line}\n`) + gotEvent = true + } + }) + + child.stderr.on("data", (chunk: Buffer) => process.stderr.write(`[bridge] ${chunk.toString()}`)) + + // Give the bridge a moment to connect, then broadcast a TaskEvent. + await new Promise((resolve) => setTimeout(resolve, 500)) + + process.stderr.write("[serve-demo] broadcasting taskStarted event\n") + + server.broadcast({ + type: IpcMessageType.TaskEvent, + origin: IpcOrigin.Server, + data: { + eventName: RooCodeEventName.TaskStarted, + payload: ["task-serve-demo-123"], + } as TaskEvent, + }) + + // Wait for the bridge to stream it back. + await new Promise((resolve) => setTimeout(resolve, 500)) + + child.kill("SIGTERM") + + await new Promise((resolve) => { + child.on("close", (code) => { + process.stderr.write(`\n[serve-demo] bridge exited with code ${code}\n`) + resolve() + }) + }) + + try { + fs.unlinkSync(socketPath) + } catch { + // ignore + } + + process.exit(gotEvent ? 0 : 1) +} + +void main() diff --git a/packages/remote-bridge/src/main.ts b/packages/remote-bridge/src/main.ts index 6a113059b1..f8d23626d1 100644 --- a/packages/remote-bridge/src/main.ts +++ b/packages/remote-bridge/src/main.ts @@ -3,12 +3,16 @@ * Remote Bridge — Phase 1 entry point. * * This is the forked Node process. It connects to the Zoo Code extension's IPC - * server (the "API surface") over a Unix socket and demonstrates that it can - * issue API calls and receive events. Later phases will forward this traffic - * over a WebRTC data channel to a remote browser/mobile client. + * server (the "API surface") over a Unix socket. It has two modes: * - * Usage: - * tsx packages/remote-bridge/src/main.ts [--socket /tmp/zoo-code.sock] [--command get-modes|get-commands|get-models] + * - one-shot (default): run a single API call, print the response, exit. + * Usage: tsx src/main.ts [--socket ] [--command get-modes|get-commands|get-models] + * + * - long-running (--serve): stay connected, log every TaskEvent to stdout as + * newline-delimited JSON, and keep the process alive. This is the mode the + * extension forks when `zoo-code.remoteControl.enabled` is on. Later phases + * will replace the stdout log line with a WebRTC data channel forward. + * Usage: tsx src/main.ts --serve [--socket ] * * The socket path defaults to the `ROO_CODE_IPC_SOCKET_PATH` env var, matching * the variable the extension reads to start its IPC server. @@ -16,25 +20,30 @@ import os from "node:os" import path from "node:path" -import { type TaskEvent } from "@roo-code/types" +import { type TaskEvent, RooCodeEventName } from "@roo-code/types" import { Bridge } from "./bridge.js" +type CommandName = "get-modes" | "get-commands" | "get-models" + interface CliArgs { socketPath: string - command: "get-modes" | "get-commands" | "get-models" + serve: boolean + command: CommandName } function defaultSocketPath(): string { // Mirror the extension's convention. The extension only starts its IPC - // server when ROO_CODE_IPC_SOCKET_PATH is set, so we fall back to a - // sensible per-user default for local dev/demo purposes. + // server when ROO_CODE_IPC_SOCKET_PATH is set (or when the remoteControl + // setting is enabled), so we fall back to a sensible per-user default for + // local dev/demo purposes. return process.env.ROO_CODE_IPC_SOCKET_PATH ?? path.join(os.tmpdir(), `zoo-code-${os.userInfo().uid}.sock`) } function parseArgs(argv: string[]): CliArgs { const args: CliArgs = { socketPath: defaultSocketPath(), + serve: false, command: "get-modes", } @@ -43,23 +52,29 @@ function parseArgs(argv: string[]): CliArgs { if (arg === "--socket" || arg === "-s") { args.socketPath = argv[++i] ?? args.socketPath + } else if (arg === "--serve") { + args.serve = true } else if (arg === "--command" || arg === "-c") { - const value = argv[++i] as CliArgs["command"] | undefined + const value = argv[++i] as CommandName | undefined if (!value) continue - if (value && ["get-modes", "get-commands", "get-models"].includes(value)) { + if (["get-modes", "get-commands", "get-models"].includes(value)) { args.command = value } } else if (arg === "--help" || arg === "-h") { process.stdout.write( [ - "Usage: remote-bridge [--socket ] [--command get-modes|get-commands|get-models]", + "Usage: remote-bridge [--socket ] [--serve] [--command get-modes|get-commands|get-models]", + "", + "Connects to the Zoo Code IPC API surface.", "", - "Connects to the Zoo Code IPC API surface and runs a single API call.", + "Modes:", + " (default) Run a single API call, print the response, and exit.", + " --serve Stay connected and stream TaskEvents as newline-delimited JSON.", "", "Options:", " --socket, -s Unix socket path (default: $ROO_CODE_IPC_SOCKET_PATH or /tmp/zoo-code-.sock)", - " --command, -c API call to run (default: get-modes)", + " --command, -c API call to run in one-shot mode (default: get-modes)", " --help, -h Show this help", ].join("\n") + "\n", ) @@ -70,7 +85,7 @@ function parseArgs(argv: string[]): CliArgs { return args } -async function runCommand(bridge: Bridge, command: CliArgs["command"]): Promise { +async function runCommand(bridge: Bridge, command: CommandName): Promise { switch (command) { case "get-modes": return bridge.getModes() @@ -81,6 +96,49 @@ async function runCommand(bridge: Bridge, command: CliArgs["command"]): Promise< } } +/** Emit a TaskEvent to stdout as a single JSON line (the --serve wire format). */ +function emitEvent(event: TaskEvent): void { + process.stdout.write(JSON.stringify(event) + "\n") +} + +async function runOneShot(bridge: Bridge, log: (...data: unknown[]) => void, command: CommandName): Promise { + log(`running command: ${command}`) + + try { + const event = await runCommand(bridge, command) + + // Pretty-print the API response to stdout so it's easy to pipe/inspect. + process.stdout.write(JSON.stringify(event, null, 2) + "\n") + + log(`received ${event.eventName} response`) + } catch (error) { + log(`command failed: ${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } finally { + bridge.disconnect() + // node-ipc keeps the event loop alive; the one-shot CLI must exit + // explicitly once the API call has completed. + process.exit(process.exitCode ?? 0) + } +} + +async function runServe(bridge: Bridge, log: (...data: unknown[]) => void): Promise { + // Forward every TaskEvent to stdout as newline-delimited JSON. Phase 2 will + // replace this with a WebRTC data channel write. + bridge.onEvent(RooCodeEventName.Message, emitEvent) + bridge.onEvent(RooCodeEventName.TaskStarted, emitEvent) + bridge.onEvent(RooCodeEventName.TaskCompleted, emitEvent) + bridge.onEvent(RooCodeEventName.TaskAborted, emitEvent) + bridge.onEvent(RooCodeEventName.TaskInteractive, emitEvent) + bridge.onEvent(RooCodeEventName.TaskIdle, emitEvent) + bridge.onEvent(RooCodeEventName.TaskAskResponded, emitEvent) + bridge.onEvent(RooCodeEventName.TaskUserMessage, emitEvent) + + log("serving: streaming TaskEvents to stdout (ndjson). Ctrl-C to stop.") + + // Intentionally do not exit — the extension owns this process's lifetime. +} + async function main() { const args = parseArgs(process.argv.slice(2)) @@ -104,27 +162,16 @@ async function main() { await bridge.connect() } catch (error) { log(`failed to connect: ${error instanceof Error ? error.message : String(error)}`) - log("hint: ensure the extension is running with ROO_CODE_IPC_SOCKET_PATH set to the same path") + log("hint: ensure the extension is running with the IPC server enabled on the same socket path") process.exit(1) } - log(`connected (clientId ready=${bridge.isReady}); running command: ${args.command}`) - - try { - const event = await runCommand(bridge, args.command) - - // Pretty-print the API response to stdout so it's easy to pipe/inspect. - process.stdout.write(JSON.stringify(event, null, 2) + "\n") + log(`connected (clientId ready=${bridge.isReady})`) - log(`received ${event.eventName} response`) - } catch (error) { - log(`command failed: ${error instanceof Error ? error.message : String(error)}`) - process.exitCode = 1 - } finally { - bridge.disconnect() - // node-ipc keeps the event loop alive; the CLI is a one-shot, so exit - // explicitly once the API call has completed. - process.exit(process.exitCode ?? 0) + if (args.serve) { + await runServe(bridge, log) + } else { + await runOneShot(bridge, log, args.command) } } diff --git a/src/esbuild.mjs b/src/esbuild.mjs index 8159581f36..b0fbf1abf8 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -138,13 +138,30 @@ async function main() { outdir: "dist/workers", } - const [extensionCtx, workerCtx] = await Promise.all([ + // The remote bridge is a standalone forked Node process (see issue #650). + // It is bundled separately so the extension can `child_process.fork` it + // from `dist/remote-bridge/main.js` when `zoo-code.remoteControl.enabled` + // is on. node-ipc and the @roo-code/* workspace deps are bundled in. + /** + * @type {import('esbuild').BuildOptions} + */ + const bridgeConfig = { + ...buildOptions, + entryPoints: ["../packages/remote-bridge/src/main.ts"], + outdir: "dist/remote-bridge", + // The bridge runs as its own process, not in the extension host, so it + // must not be treated as part of the extension bundle. + external: [...(buildOptions.external ?? []), "vscode"], + } + + const [extensionCtx, workerCtx, bridgeCtx] = await Promise.all([ esbuild.context(extensionConfig), esbuild.context(workerConfig), + esbuild.context(bridgeConfig), ]) if (watch) { - await Promise.all([extensionCtx.watch(), workerCtx.watch()]) + await Promise.all([extensionCtx.watch(), workerCtx.watch(), bridgeCtx.watch()]) copyLocales(srcDir, distDir) setupLocaleWatcher(srcDir, distDir) } else { @@ -152,7 +169,8 @@ async function main() { // onEnd hooks copy the same asset directories concurrently. await extensionCtx.rebuild() await workerCtx.rebuild() - await Promise.all([extensionCtx.dispose(), workerCtx.dispose()]) + await bridgeCtx.rebuild() + await Promise.all([extensionCtx.dispose(), workerCtx.dispose(), bridgeCtx.dispose()]) } } diff --git a/src/extension.ts b/src/extension.ts index e326509c3d..77cc20c5d9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import * as dotenvx from "@dotenvx/dotenvx" import * as fs from "fs" +import * as os from "os" import * as path from "path" // Load environment variables from .env file @@ -24,6 +25,7 @@ import { customToolRegistry } from "@roo-code/core" import "./utils/path" // Necessary to have access to String.prototype.toPosix. import { createOutputChannelLogger, createDualLogger } from "./utils/outputChannelLogger" +import { RemoteBridgeHost, resolveBridgeModulePath } from "./services/remote-bridge/RemoteBridgeHost" import { initializeNetworkProxy } from "./utils/networkProxy" import { Package } from "./shared/package" @@ -63,6 +65,7 @@ import { initZooCodeAuth } from "./services/zoo-code-auth" let outputChannel: vscode.OutputChannel let extensionContext: vscode.ExtensionContext let cloudService: CloudService | undefined +let remoteBridgeHost: RemoteBridgeHost | undefined let authStateChangedHandler: ((data: { state: AuthState; previousState: AuthState }) => Promise) | undefined let settingsUpdatedHandler: (() => void) | undefined @@ -299,8 +302,22 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand(`${Package.name}.activationCompleted`) // Implements the `RooCodeAPI` interface. - const socketPath = process.env.ROO_CODE_IPC_SOCKET_PATH - const enableLogging = typeof socketPath === "string" + // + // The IPC server starts when either: + // - `zoo-code.remoteControl.enabled` is on, or + // - the legacy `ROO_CODE_IPC_SOCKET_PATH` env var is set. + // The bridge process is forked only when the setting is on (the env var + // alone is for headless/CLI use and does not auto-fork the bridge). + const remoteControlConfig = vscode.workspace.getConfiguration("zoo-code.remoteControl") + const remoteControlEnabled = Boolean(remoteControlConfig.get("enabled")) + const configuredSocketPath = remoteControlConfig.get("socketPath") ?? "" + const envSocketPath = process.env.ROO_CODE_IPC_SOCKET_PATH + + const socketPath = + configuredSocketPath || envSocketPath || path.join(os.tmpdir(), `zoo-code-${os.userInfo().uid}.sock`) + const ipcEnabled = remoteControlEnabled || typeof envSocketPath === "string" + const enableLogging = ipcEnabled + const effectiveSocketPath = ipcEnabled ? socketPath : undefined // Watch the core files and automatically reload the extension host. if (process.env.NODE_ENV === "development") { @@ -357,7 +374,74 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize background model cache refresh initializeModelCacheRefresh() - return new API(outputChannel, provider, socketPath, enableLogging) + const api = new API(outputChannel, provider, effectiveSocketPath, enableLogging) + + // Fork the remote bridge process when Remote Control is enabled. + if (remoteControlEnabled && effectiveSocketPath) { + startRemoteBridge(context, outputChannel, effectiveSocketPath) + } + + // Hot-toggle Remote Control without restarting the extension host. + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (!e.affectsConfiguration("zoo-code.remoteControl")) { + return + } + + const config = vscode.workspace.getConfiguration("zoo-code.remoteControl") + const enabled = Boolean(config.get("enabled")) + const newSocketPath = config.get("socketPath") ?? "" + const resolvedSocketPath = + newSocketPath || envSocketPath || path.join(os.tmpdir(), `zoo-code-${os.userInfo().uid}.sock`) + + if (enabled && resolvedSocketPath) { + if ( + remoteBridgeHost && + remoteBridgeHost.socketPath === resolvedSocketPath && + remoteBridgeHost.isRunning + ) { + return + } + + startRemoteBridge(context, outputChannel, resolvedSocketPath) + } else { + if (remoteBridgeHost) { + remoteBridgeHost.stop() + remoteBridgeHost = undefined + } + } + }), + ) + + return api +} + +/** + * Fork the remote bridge process (issue #650, Phase 1). The bridge connects to + * the extension's IPC server over the Unix socket and streams TaskEvents. If a + * host is already running it is restarted against the (possibly new) socket. + */ +function startRemoteBridge( + context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel, + socketPath: string, +): void { + const bridgeModulePath = resolveBridgeModulePath(context.asAbsolutePath("dist")) + + if (remoteBridgeHost) { + remoteBridgeHost.dispose() + remoteBridgeHost = undefined + } + + remoteBridgeHost = new RemoteBridgeHost({ + bridgeModulePath, + log: createOutputChannelLogger(outputChannel), + }) + + remoteBridgeHost.start(socketPath) + + // Ensure the bridge is torn down with the extension host. + context.subscriptions.push({ dispose: () => remoteBridgeHost?.dispose() }) } // This method is called when your extension is deactivated. @@ -386,6 +470,9 @@ export async function deactivate() { } } + remoteBridgeHost?.dispose() + remoteBridgeHost = undefined + await McpServerManager.cleanup(extensionContext) TelemetryService.instance.shutdown() Terminal.setTerminalProfile(undefined) diff --git a/src/package.json b/src/package.json index 4b46f4f638..f23624ce9e 100644 --- a/src/package.json +++ b/src/package.json @@ -434,6 +434,20 @@ "scope": "machine", "description": "%settings.workspace.rootResolution.description%", "markdownDescription": "%settings.workspace.rootResolution.description%" + }, + "zoo-code.remoteControl.enabled": { + "type": "boolean", + "default": false, + "scope": "machine", + "description": "%settings.remoteControl.enabled.description%", + "markdownDescription": "%settings.remoteControl.enabled.description%" + }, + "zoo-code.remoteControl.socketPath": { + "type": "string", + "default": "", + "scope": "machine", + "description": "%settings.remoteControl.socketPath.description%", + "markdownDescription": "%settings.remoteControl.socketPath.description%" } } } diff --git a/src/package.nls.json b/src/package.nls.json index 4fac644eab..e21e25b1b0 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -48,5 +48,7 @@ "settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging.", "settings.workspace.rootResolution.description": "How Zoo resolves the workspace root in a multi-root workspace. The root is used to locate `.roomodes`, `.roo/mcp.json`, `.roo/rules/`, and other project-scoped configuration. Changing this setting only affects future lookups; running tasks keep their original root.", "settings.workspace.rootResolution.activeEditor.description": "Use the workspace folder containing the active editor; fall back to the first workspace folder. (Default — preserves legacy behavior.)", - "settings.workspace.rootResolution.firstFolder.description": "Always use the first workspace folder (workspaceFolders[0]). Deterministic — independent of which file is currently focused." + "settings.workspace.rootResolution.firstFolder.description": "Always use the first workspace folder (workspaceFolders[0]). Deterministic — independent of which file is currently focused.", + "settings.remoteControl.enabled.description": "**Enable Remote Control** — Starts the IPC server and forks a background bridge process so Zoo Code's task stream and approval flow can be reached from another device. Opt-in: no socket is opened and no process is forked unless this is on. Changes apply without restarting.", + "settings.remoteControl.socketPath.description": "Unix socket path the IPC server listens on and the bridge connects to. Leave blank to use a per-user default under the system temp directory (`$ROO_CODE_IPC_SOCKET_PATH` overrides this if set). Only used when **Remote Control** is enabled." } diff --git a/src/services/remote-bridge/RemoteBridgeHost.ts b/src/services/remote-bridge/RemoteBridgeHost.ts new file mode 100644 index 0000000000..cd2e267009 --- /dev/null +++ b/src/services/remote-bridge/RemoteBridgeHost.ts @@ -0,0 +1,201 @@ +import * as childProcess from "child_process" +import * as path from "path" + +import type { LogFunction } from "../../utils/outputChannelLogger" + +/** + * RemoteBridgeHost owns the lifecycle of the forked remote-bridge Node process + * (issue #650, Phase 1). + * + * When `zoo-code.remoteControl.enabled` is on, the extension forks the bundled + * bridge (`dist/remote-bridge/main.js`) in `--serve` mode pointed at the IPC + * socket. The bridge connects to the extension's `IpcServer` and streams + * `TaskEvent`s. This host starts/stops/restarts that child process and pipes + * its output to the Zoo Code output channel. + * + * The fork implementation is injectable so the lifecycle logic can be unit + * tested without spawning real processes. + */ + +export type ForkFn = ( + modulePath: string, + args: string[], + options: childProcess.ForkOptions, +) => childProcess.ChildProcess + +export interface RemoteBridgeHostOptions { + /** Absolute path to the bundled bridge entry (dist/remote-bridge/main.js). */ + bridgeModulePath: string + /** Logger (typically the Zoo Code output channel). */ + log?: LogFunction + /** Inject a fork implementation (defaults to child_process.fork). */ + fork?: ForkFn + /** Max restart attempts after a crash before giving up. */ + maxRestarts?: number + /** Base delay (ms) for restart backoff. */ + restartDelayMs?: number +} + +export class RemoteBridgeHost { + private readonly _bridgeModulePath: string + private readonly _log: LogFunction + private readonly _fork: ForkFn + private readonly _maxRestarts: number + private readonly _restartDelayMs: number + + private _child: childProcess.ChildProcess | undefined + private _socketPath: string | undefined + private _restartTimer: NodeJS.Timeout | undefined + private _restartCount = 0 + private _stopped = false + + constructor(options: RemoteBridgeHostOptions) { + this._bridgeModulePath = options.bridgeModulePath + this._log = options.log ?? (() => {}) + this._fork = options.fork ?? childProcess.fork + this._maxRestarts = options.maxRestarts ?? 5 + this._restartDelayMs = options.restartDelayMs ?? 2_000 + } + + public get isRunning(): boolean { + return this._child !== undefined && !this._child.killed + } + + public get socketPath(): string | undefined { + return this._socketPath + } + + /** Fork the bridge in --serve mode against the given socket path. */ + public start(socketPath: string): void { + if (this.isRunning) { + this._log(`[remote-bridge] start requested but already running on ${this._socketPath}`) + return + } + + this._stopped = false + this._socketPath = socketPath + this._restartCount = 0 + this.spawn() + } + + /** Stop the bridge and cancel any pending restart. */ + public stop(): void { + this._stopped = true + + if (this._restartTimer) { + clearTimeout(this._restartTimer) + this._restartTimer = undefined + } + + const child = this._child + + if (child && !child.killed) { + this._log(`[remote-bridge] stopping bridge process (pid=${child.pid})`) + child.removeAllListeners() + child.kill("SIGTERM") + } + + this._child = undefined + } + + /** Restart the bridge against the same socket path. */ + public restart(): void { + if (!this._socketPath) { + return + } + + const socketPath = this._socketPath + this.stop() + this._stopped = false + this._restartCount = 0 + this.start(socketPath) + } + + public dispose(): void { + this.stop() + } + + private spawn(): void { + const socketPath = this._socketPath + + if (!socketPath) { + return + } + + this._log(`[remote-bridge] forking bridge: ${this._bridgeModulePath} --socket ${socketPath} --serve`) + + let child: childProcess.ChildProcess + + try { + child = this._fork(this._bridgeModulePath, ["--socket", socketPath, "--serve"], { + stdio: ["ignore", "pipe", "pipe"], + }) + } catch (error) { + this._log( + `[remote-bridge] failed to fork bridge: ${error instanceof Error ? error.message : String(error)}`, + ) + this.scheduleRestart() + return + } + + this._child = child + + child.on("exit", (code, signal) => { + this._log(`[remote-bridge] bridge process exited (code=${code}, signal=${signal})`) + + // Only the unexpected exits trigger restart; a deliberate stop() + // sets _stopped and clears _child before the exit handler runs. + if (this._child === child) { + this._child = undefined + + if (!this._stopped) { + this.scheduleRestart() + } + } + }) + + if (child.stdout) { + child.stdout.on("data", (chunk: Buffer) => { + this._log(`[remote-bridge:out] ${chunk.toString().trimEnd()}`) + }) + } + + if (child.stderr) { + child.stderr.on("data", (chunk: Buffer) => { + this._log(`[remote-bridge:err] ${chunk.toString().trimEnd()}`) + }) + } + } + + private scheduleRestart(): void { + if (this._stopped) { + return + } + + if (this._restartCount >= this._maxRestarts) { + this._log( + `[remote-bridge] giving up after ${this._restartCount} restart attempts. Re-enable Remote Control to retry.`, + ) + return + } + + const delay = this._restartDelayMs * Math.pow(2, this._restartCount) + this._restartCount += 1 + + this._log(`[remote-bridge] scheduling restart #${this._restartCount} in ${delay}ms`) + + this._restartTimer = setTimeout(() => { + this._restartTimer = undefined + this.spawn() + }, delay) + } +} + +/** + * Resolve the bundled bridge entry path relative to the extension's dist dir. + * The extension is bundled to `dist/extension.js`, so `__dirname` is the dist + * directory and the bridge lives at `dist/remote-bridge/main.js`. + */ +export function resolveBridgeModulePath(extensionDistDir: string): string { + return path.join(extensionDistDir, "remote-bridge", "main.js") +} diff --git a/src/services/remote-bridge/__tests__/RemoteBridgeHost.spec.ts b/src/services/remote-bridge/__tests__/RemoteBridgeHost.spec.ts new file mode 100644 index 0000000000..5ff4055c9a --- /dev/null +++ b/src/services/remote-bridge/__tests__/RemoteBridgeHost.spec.ts @@ -0,0 +1,176 @@ +import { EventEmitter } from "events" + +import { RemoteBridgeHost, type ForkFn } from "../RemoteBridgeHost" + +/** + * A minimal ChildProcess stand-in for unit testing the host lifecycle without + * spawning real processes. It implements the surface area the host touches: + * pid, killed, kill(), removeAllListeners(), and stdout/stderr EventEmitters. + */ +function createFakeChild(): any { + const child = new EventEmitter() as any + child.pid = 12345 + child.killed = false + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.kill = (signal?: string) => { + child.killed = true + // Defer the exit so listeners (registered after fork) receive it. + setImmediate(() => child.emit("exit", 0, signal ?? null)) + return true + } + return child +} + +describe("RemoteBridgeHost", () => { + it("forks the bridge in --serve mode with the socket path", () => { + const fork = vi.fn(() => createFakeChild()) + const log = vi.fn() + const host = new RemoteBridgeHost({ + bridgeModulePath: "/fake/dist/remote-bridge/main.js", + log, + fork, + }) + + host.start("/tmp/zoo-code.sock") + + expect(fork).toHaveBeenCalledTimes(1) + expect(fork).toHaveBeenCalledWith( + "/fake/dist/remote-bridge/main.js", + ["--socket", "/tmp/zoo-code.sock", "--serve"], + expect.objectContaining({ stdio: ["ignore", "pipe", "pipe"] }), + ) + expect(host.isRunning).toBe(true) + expect(host.socketPath).toBe("/tmp/zoo-code.sock") + + host.stop() + }) + + it("does not fork twice when already running", () => { + const fork = vi.fn(() => createFakeChild()) + const host = new RemoteBridgeHost({ bridgeModulePath: "/fake/main.js", fork }) + + host.start("/tmp/sock") + host.start("/tmp/sock") + + expect(fork).toHaveBeenCalledTimes(1) + host.stop() + }) + + it("stop() kills the child and cancels restarts", () => { + const child = createFakeChild() + const fork = vi.fn(() => child) + const host = new RemoteBridgeHost({ bridgeModulePath: "/fake/main.js", fork }) + + host.start("/tmp/sock") + expect(host.isRunning).toBe(true) + + host.stop() + + expect(child.killed).toBe(true) + expect(host.isRunning).toBe(false) + }) + + it("restarts with backoff after an unexpected exit", async () => { + vi.useFakeTimers() + const children: any[] = [] + const fork = vi.fn(() => { + const child = createFakeChild() + children.push(child) + return child + }) + const log = vi.fn() + const host = new RemoteBridgeHost({ + bridgeModulePath: "/fake/main.js", + fork, + log, + maxRestarts: 3, + restartDelayMs: 100, + }) + + host.start("/tmp/sock") + expect(fork).toHaveBeenCalledTimes(1) + + // Simulate an unexpected crash (not via stop()). + children[0]!.emit("exit", 1, null) + + // A restart should be scheduled with backoff. + expect(log).toHaveBeenCalledWith(expect.stringContaining("scheduling restart #1")) + + // Advance past the first backoff delay (100 * 2^0 = 100ms). + await vi.advanceTimersByTimeAsync(100) + expect(fork).toHaveBeenCalledTimes(2) + + host.stop() + vi.useRealTimers() + }) + + it("gives up after maxRestarts attempts", async () => { + vi.useFakeTimers() + const fork = vi.fn(() => createFakeChild()) + const log = vi.fn() + const host = new RemoteBridgeHost({ + bridgeModulePath: "/fake/main.js", + fork, + log, + maxRestarts: 2, + restartDelayMs: 50, + }) + + host.start("/tmp/sock") + + // Crash the first child. + const first = fork.mock.results[0]!.value as any + first.emit("exit", 1, null) + await vi.advanceTimersByTimeAsync(50) // restart #1 + + // Crash the second child. + const second = fork.mock.results[1]!.value as any + second.emit("exit", 1, null) + await vi.advanceTimersByTimeAsync(100) // restart #2 + + // Crash the third child — should give up, not schedule a 4th fork. + const third = fork.mock.results[2]!.value as any + third.emit("exit", 1, null) + await vi.advanceTimersByTimeAsync(200) + + expect(fork).toHaveBeenCalledTimes(3) + expect(log).toHaveBeenCalledWith(expect.stringContaining("giving up")) + + host.stop() + vi.useRealTimers() + }) + + it("restart() stops and re-forks against the same socket", () => { + const fork = vi.fn(() => createFakeChild()) + const host = new RemoteBridgeHost({ bridgeModulePath: "/fake/main.js", fork }) + + host.start("/tmp/sock") + const firstChild = fork.mock.results[0]!.value as any + + host.restart() + + expect(firstChild.killed).toBe(true) + expect(fork).toHaveBeenCalledTimes(2) + expect(host.socketPath).toBe("/tmp/sock") + + host.stop() + }) + + it("pipes stdout/stderr to the logger", () => { + const child = createFakeChild() + const fork = vi.fn(() => child) + const log = vi.fn() + const host = new RemoteBridgeHost({ bridgeModulePath: "/fake/main.js", fork, log }) + + host.start("/tmp/sock") + + child.stdout.emit("data", Buffer.from("hello stdout\n")) + child.stderr.emit("data", Buffer.from("hello stderr\n")) + + expect(log).toHaveBeenCalledWith(expect.stringContaining("hello stdout")) + expect(log).toHaveBeenCalledWith(expect.stringContaining("hello stderr")) + + host.stop() + }) +}) From 4d6457fafbe75cbdbaa89d10968b98b513b45ae9 Mon Sep 17 00:00:00 2001 From: Naved Date: Sun, 21 Jun 2026 16:42:26 -0700 Subject: [PATCH 3/5] enable remote mode in settings --- packages/remote-bridge/README.md | 8 ++-- packages/types/src/global-settings.ts | 13 +++++++ packages/types/src/vscode-extension-host.ts | 2 + src/core/webview/ClineProvider.ts | 28 ++++++++++++++ .../webview/__tests__/ClineProvider.spec.ts | 24 ++++++++++++ .../__tests__/webviewMessageHandler.spec.ts | 37 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 12 ++++++ src/extension.ts | 26 ++++++------- src/package.json | 14 ------- src/package.nls.json | 4 +- .../remote-bridge/RemoteBridgeHost.ts | 5 ++- .../__tests__/RemoteBridgeHost.spec.ts | 2 +- .../src/components/settings/SettingsView.tsx | 17 +++++++++ webview-ui/src/i18n/locales/en/settings.json | 13 +++++++ 14 files changed, 167 insertions(+), 38 deletions(-) diff --git a/packages/remote-bridge/README.md b/packages/remote-bridge/README.md index 4bc06d60c5..a9c1f055cd 100644 --- a/packages/remote-bridge/README.md +++ b/packages/remote-bridge/README.md @@ -23,7 +23,7 @@ What it does **not** do yet (later phases): WebRTC data channel, signaling, remo └──────────────────────┘ └──────────────────────┘ ``` -The socket path is the same one the extension already reads: the `ROO_CODE_IPC_SOCKET_PATH` environment variable. The extension starts its IPC server when either `zoo-code.remoteControl.enabled` is on **or** `ROO_CODE_IPC_SOCKET_PATH` is set, so the bridge is opt-in. The bridge process itself is only auto-forked when the setting is on (the env var alone is for headless/CLI use and does not auto-fork). +The socket path is the same one the extension already reads: the `ROO_CODE_IPC_SOCKET_PATH` environment variable. The extension starts its IPC server when either the **Remote Control** setting is on (persisted via the ContextProxy / `GlobalSettings` as `remoteControlEnabled` / `remoteControlSocketPath`, surfaced in the Zoo Code SettingsView) **or** `ROO_CODE_IPC_SOCKET_PATH` is set, so the bridge is opt-in. The bridge process itself is only auto-forked when the setting is on (the env var alone is for headless/CLI use and does not auto-fork). ## Usage @@ -62,11 +62,11 @@ The response event is pretty-printed to stdout; diagnostic logs go to stderr. ## Enabling from Zoo Code preferences -Toggle **Settings → Zoo Code → Remote Control: Enabled** (`zoo-code.remoteControl.enabled`). When on, the extension: +Toggle **Settings → Remote Control → Enable Remote Control** in the Zoo Code SettingsView (persisted as `remoteControlEnabled` / `remoteControlSocketPath` through the ContextProxy / `GlobalSettings`). When on, the extension: -1. Starts its `IpcServer` on the configured socket (`zoo-code.remoteControl.socketPath`, or a per-user default under the system temp dir; `ROO_CODE_IPC_SOCKET_PATH` overrides if set). +1. Starts its `IpcServer` on the configured socket (`remoteControlSocketPath`, or a per-user default under the system temp dir; `ROO_CODE_IPC_SOCKET_PATH` overrides if set). 2. Forks this bridge in `--serve` mode against that socket via [`RemoteBridgeHost`](../../src/services/remote-bridge/RemoteBridgeHost.ts), with crash-restart backoff. -3. Hot-toggles without a restart via a config-change listener in [`src/extension.ts`](../../src/extension.ts). +3. Hot-toggles without a restart: the webview `updateSettings` handler persists the new values to the ContextProxy and fires [`ClineProvider#onRemoteControlChange`](../../src/core/webview/ClineProvider.ts), which [`src/extension.ts`](../../src/extension.ts) subscribes to. The bridge is bundled into the extension VSIX at `dist/remote-bridge/main.js` by [`src/esbuild.mjs`](../../src/esbuild.mjs). diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index bac3548ccb..cfb371a3ca 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -236,6 +236,19 @@ export const globalSettingsSchema = z.object({ */ showWorktreesInHomeScreen: z.boolean().optional(), + /** + * Remote Control (issue #650): when true, the extension starts its IPC + * server and forks the remote bridge process so the task stream and + * approval flow can be reached from another device. Opt-in. + */ + remoteControlEnabled: z.boolean().optional(), + /** + * Remote Control: Unix socket path the IPC server listens on and the + * bridge connects to. Leave blank to use a per-user default under the + * system temp directory (`ROO_CODE_IPC_SOCKET_PATH` overrides if set). + */ + remoteControlSocketPath: z.string().optional(), + /** * List of native tool names to globally disable. * Tools in this list will be excluded from prompt generation and rejected at execution time. diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 2cf42c342b..f4dc1358ed 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -318,6 +318,8 @@ export type ExtensionState = Pick< | "requestDelaySeconds" | "showWorktreesInHomeScreen" | "disabledTools" + | "remoteControlEnabled" + | "remoteControlSocketPath" > & { lockApiConfigAcrossModes?: boolean version: string diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b2bb73e982..42707bbe97 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2281,6 +2281,8 @@ export class ClineProvider autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles, + remoteControlEnabled, + remoteControlSocketPath, } = await this.getState() let cloudOrganizations: CloudOrganizationMembership[] = [] @@ -2463,6 +2465,8 @@ export class ClineProvider autoCloseZooOpenedFiles: autoCloseZooOpenedFiles ?? true, autoCloseZooOpenedFilesAfterUserEdited: autoCloseZooOpenedFilesAfterUserEdited ?? false, autoCloseZooOpenedNewFiles: autoCloseZooOpenedNewFiles ?? false, + remoteControlEnabled: remoteControlEnabled ?? false, + remoteControlSocketPath: remoteControlSocketPath ?? "", openAiCodexIsAuthenticated: await (async () => { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") @@ -2666,6 +2670,8 @@ export class ClineProvider autoCloseZooOpenedFiles: stateValues.autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited: stateValues.autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles: stateValues.autoCloseZooOpenedNewFiles, + remoteControlEnabled: stateValues.remoteControlEnabled ?? false, + remoteControlSocketPath: stateValues.remoteControlSocketPath ?? "", } } @@ -2778,6 +2784,28 @@ export class ClineProvider return this.contextProxy.getValues() } + // Remote Control (issue #650): notify the extension host when the + // `remoteControlEnabled` / `remoteControlSocketPath` settings change via the + // webview so it can hot-toggle the IPC server + forked bridge without a + // restart. The extension subscribes via `onRemoteControlChange`. + private readonly _remoteControlChangeEmitter = new vscode.EventEmitter<{ + enabled: boolean + socketPath: string + }>() + + public readonly onRemoteControlChange = this._remoteControlChangeEmitter.event + + /** + * Fire the remote-control change event with the current values from the + * ContextProxy. Called by the `updateSettings` webview message handler + * after it persists `remoteControlEnabled` / `remoteControlSocketPath`. + */ + public notifyRemoteControlChange(): void { + const enabled = Boolean(this.contextProxy.getValue("remoteControlEnabled")) + const socketPath = this.contextProxy.getValue("remoteControlSocketPath") ?? "" + this._remoteControlChangeEmitter.fire({ enabled, socketPath }) + } + public async setValues(values: RooCodeSettings) { await this.contextProxy.setValues(values) } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 158238ce7e..a56a961306 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1043,6 +1043,30 @@ describe("ClineProvider", () => { expect(state.autoCloseZooOpenedNewFiles).toBe(false) }) + it("getStateToPostToWebview returns saved remoteControl values", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.contextProxy.setValue("remoteControlEnabled", true) + await provider.contextProxy.setValue("remoteControlSocketPath", "/tmp/zoo-test.sock") + + const state = await provider.getStateToPostToWebview() + + expect(state.remoteControlEnabled).toBe(true) + expect(state.remoteControlSocketPath).toBe("/tmp/zoo-test.sock") + }) + + it("getStateToPostToWebview defaults remoteControl values when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.contextProxy.setValue("remoteControlEnabled", undefined) + await provider.contextProxy.setValue("remoteControlSocketPath", undefined) + + const state = await provider.getStateToPostToWebview() + + expect(state.remoteControlEnabled).toBe(false) + expect(state.remoteControlSocketPath).toBe("") + }) + it("getState returns saved autoCloseZooOpenedFiles value for DiffViewProvider", async () => { await provider.resolveWebviewView(mockWebviewView) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 9704a7229d..191d3dc7ae 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -82,6 +82,7 @@ const mockClineProvider = { }, log: vi.fn(), postStateToWebview: vi.fn(), + notifyRemoteControlChange: vi.fn(), getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), createTaskWithHistoryItem: vi.fn(), @@ -1339,3 +1340,39 @@ describe("zooCodeSignOut", () => { ) }) }) + +describe("webviewMessageHandler - remoteControl", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("persists remoteControlEnabled and notifies the extension host", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { remoteControlEnabled: true }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("remoteControlEnabled", true) + expect(mockClineProvider.notifyRemoteControlChange).toHaveBeenCalledTimes(1) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + }) + + it("persists remoteControlSocketPath and notifies the extension host", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { remoteControlSocketPath: "/tmp/zoo.sock" }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("remoteControlSocketPath", "/tmp/zoo.sock") + expect(mockClineProvider.notifyRemoteControlChange).toHaveBeenCalledTimes(1) + }) + + it("does not notify when unrelated settings change", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { soundEnabled: true }, + }) + + expect(mockClineProvider.notifyRemoteControlChange).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5a827d5126..14724e0eac 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -664,6 +664,8 @@ export const webviewMessageHandler = async ( case "updateSettings": if (message.updatedSettings) { + let touchedRemoteControl = false + for (const [key, value] of Object.entries(message.updatedSettings)) { let newValue = value @@ -763,9 +765,19 @@ export const webviewMessageHandler = async ( } } + if (key === "remoteControlEnabled" || key === "remoteControlSocketPath") { + touchedRemoteControl = true + } + await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) } + // Hot-toggle the IPC server + forked bridge when the Remote Control + // settings change via the webview (issue #650). + if (touchedRemoteControl) { + provider.notifyRemoteControlChange() + } + await provider.postStateToWebview() } diff --git a/src/extension.ts b/src/extension.ts index 77cc20c5d9..c47424d8fb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -303,14 +303,15 @@ export async function activate(context: vscode.ExtensionContext) { // Implements the `RooCodeAPI` interface. // - // The IPC server starts when either: - // - `zoo-code.remoteControl.enabled` is on, or + // Remote Control (issue #650) is configured via the Zoo Code SettingsView + // and persisted through the ContextProxy (GlobalSettings). The IPC server + // starts when either: + // - `remoteControlEnabled` is on, or // - the legacy `ROO_CODE_IPC_SOCKET_PATH` env var is set. // The bridge process is forked only when the setting is on (the env var // alone is for headless/CLI use and does not auto-fork the bridge). - const remoteControlConfig = vscode.workspace.getConfiguration("zoo-code.remoteControl") - const remoteControlEnabled = Boolean(remoteControlConfig.get("enabled")) - const configuredSocketPath = remoteControlConfig.get("socketPath") ?? "" + const remoteControlEnabled = Boolean(contextProxy.getValue("remoteControlEnabled")) + const configuredSocketPath = contextProxy.getValue("remoteControlSocketPath") ?? "" const envSocketPath = process.env.ROO_CODE_IPC_SOCKET_PATH const socketPath = @@ -381,18 +382,13 @@ export async function activate(context: vscode.ExtensionContext) { startRemoteBridge(context, outputChannel, effectiveSocketPath) } - // Hot-toggle Remote Control without restarting the extension host. + // Hot-toggle Remote Control without restarting the extension host. The + // webview `updateSettings` handler persists the new values to the + // ContextProxy and then fires `provider.onRemoteControlChange`. context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration((e) => { - if (!e.affectsConfiguration("zoo-code.remoteControl")) { - return - } - - const config = vscode.workspace.getConfiguration("zoo-code.remoteControl") - const enabled = Boolean(config.get("enabled")) - const newSocketPath = config.get("socketPath") ?? "" + provider.onRemoteControlChange(({ enabled, socketPath }) => { const resolvedSocketPath = - newSocketPath || envSocketPath || path.join(os.tmpdir(), `zoo-code-${os.userInfo().uid}.sock`) + socketPath || envSocketPath || path.join(os.tmpdir(), `zoo-code-${os.userInfo().uid}.sock`) if (enabled && resolvedSocketPath) { if ( diff --git a/src/package.json b/src/package.json index f23624ce9e..4b46f4f638 100644 --- a/src/package.json +++ b/src/package.json @@ -434,20 +434,6 @@ "scope": "machine", "description": "%settings.workspace.rootResolution.description%", "markdownDescription": "%settings.workspace.rootResolution.description%" - }, - "zoo-code.remoteControl.enabled": { - "type": "boolean", - "default": false, - "scope": "machine", - "description": "%settings.remoteControl.enabled.description%", - "markdownDescription": "%settings.remoteControl.enabled.description%" - }, - "zoo-code.remoteControl.socketPath": { - "type": "string", - "default": "", - "scope": "machine", - "description": "%settings.remoteControl.socketPath.description%", - "markdownDescription": "%settings.remoteControl.socketPath.description%" } } } diff --git a/src/package.nls.json b/src/package.nls.json index e21e25b1b0..4fac644eab 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -48,7 +48,5 @@ "settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging.", "settings.workspace.rootResolution.description": "How Zoo resolves the workspace root in a multi-root workspace. The root is used to locate `.roomodes`, `.roo/mcp.json`, `.roo/rules/`, and other project-scoped configuration. Changing this setting only affects future lookups; running tasks keep their original root.", "settings.workspace.rootResolution.activeEditor.description": "Use the workspace folder containing the active editor; fall back to the first workspace folder. (Default — preserves legacy behavior.)", - "settings.workspace.rootResolution.firstFolder.description": "Always use the first workspace folder (workspaceFolders[0]). Deterministic — independent of which file is currently focused.", - "settings.remoteControl.enabled.description": "**Enable Remote Control** — Starts the IPC server and forks a background bridge process so Zoo Code's task stream and approval flow can be reached from another device. Opt-in: no socket is opened and no process is forked unless this is on. Changes apply without restarting.", - "settings.remoteControl.socketPath.description": "Unix socket path the IPC server listens on and the bridge connects to. Leave blank to use a per-user default under the system temp directory (`$ROO_CODE_IPC_SOCKET_PATH` overrides this if set). Only used when **Remote Control** is enabled." + "settings.workspace.rootResolution.firstFolder.description": "Always use the first workspace folder (workspaceFolders[0]). Deterministic — independent of which file is currently focused." } diff --git a/src/services/remote-bridge/RemoteBridgeHost.ts b/src/services/remote-bridge/RemoteBridgeHost.ts index cd2e267009..580376674c 100644 --- a/src/services/remote-bridge/RemoteBridgeHost.ts +++ b/src/services/remote-bridge/RemoteBridgeHost.ts @@ -127,8 +127,11 @@ export class RemoteBridgeHost { let child: childProcess.ChildProcess try { + // `child_process.fork` requires an IPC channel in `stdio`; the + // bridge itself communicates over the Unix socket, so we add `'ipc'` + // as a fourth stdio entry solely to satisfy fork's requirement. child = this._fork(this._bridgeModulePath, ["--socket", socketPath, "--serve"], { - stdio: ["ignore", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe", "ipc"], }) } catch (error) { this._log( diff --git a/src/services/remote-bridge/__tests__/RemoteBridgeHost.spec.ts b/src/services/remote-bridge/__tests__/RemoteBridgeHost.spec.ts index 5ff4055c9a..764607f342 100644 --- a/src/services/remote-bridge/__tests__/RemoteBridgeHost.spec.ts +++ b/src/services/remote-bridge/__tests__/RemoteBridgeHost.spec.ts @@ -38,7 +38,7 @@ describe("RemoteBridgeHost", () => { expect(fork).toHaveBeenCalledWith( "/fake/dist/remote-bridge/main.js", ["--socket", "/tmp/zoo-code.sock", "--serve"], - expect.objectContaining({ stdio: ["ignore", "pipe", "pipe"] }), + expect.objectContaining({ stdio: ["ignore", "pipe", "pipe", "ipc"] }), ) expect(host.isRunning).toBe(true) expect(host.socketPath).toBe("/tmp/zoo-code.sock") diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index a34a8634ff..e1ba166184 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -29,6 +29,7 @@ import { ArrowLeft, GitCommitVertical, GraduationCap, + Radio, } from "lucide-react" import { @@ -72,6 +73,7 @@ import { ContextManagementSettings } from "./ContextManagementSettings" import { TerminalSettings } from "./TerminalSettings" import { ExperimentalSettings } from "./ExperimentalSettings" import { LanguageSettings } from "./LanguageSettings" +import { RemoteControlSettings } from "./RemoteControlSettings" import { About } from "./About" import { Section } from "./Section" import PromptsSettings from "./PromptsSettings" @@ -111,6 +113,7 @@ export const sectionNames = [ "ui", "experimental", "language", + "remoteControl", "about", ] as const @@ -208,6 +211,8 @@ const SettingsView = forwardRef(({ onDone, t autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles, + remoteControlEnabled, + remoteControlSocketPath, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) @@ -432,6 +437,8 @@ const SettingsView = forwardRef(({ onDone, t openRouterImageGenerationSelectedModel, experiments, customSupportPrompts, + remoteControlEnabled: remoteControlEnabled ?? false, + remoteControlSocketPath: remoteControlSocketPath ?? "", }, }) @@ -533,6 +540,7 @@ const SettingsView = forwardRef(({ onDone, t { id: "ui", icon: Glasses }, { id: "experimental", icon: FlaskConical }, { id: "language", icon: Globe }, + { id: "remoteControl", icon: Radio }, { id: "about", icon: Info }, ], [], // No dependencies needed now @@ -935,6 +943,15 @@ const SettingsView = forwardRef(({ onDone, t )} + {/* Remote Control Section */} + {renderTab === "remoteControl" && ( + + )} + {/* About Section */} {renderTab === "about" && ( .sock" + } + }, "about": { "bugReport": { "label": "Found a bug?", From f2ba19ae2c6a9e5ad3283d6290e680c51c3d11c4 Mon Sep 17 00:00:00 2001 From: Naved Date: Sun, 21 Jun 2026 16:46:09 -0700 Subject: [PATCH 4/5] add remote settings --- .../settings/RemoteControlSettings.tsx | 70 ++++++++++++++++++ .../__tests__/RemoteControlSettings.spec.tsx | 71 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 webview-ui/src/components/settings/RemoteControlSettings.tsx create mode 100644 webview-ui/src/components/settings/__tests__/RemoteControlSettings.spec.tsx diff --git a/webview-ui/src/components/settings/RemoteControlSettings.tsx b/webview-ui/src/components/settings/RemoteControlSettings.tsx new file mode 100644 index 0000000000..0966b3824f --- /dev/null +++ b/webview-ui/src/components/settings/RemoteControlSettings.tsx @@ -0,0 +1,70 @@ +import { HTMLAttributes } from "react" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { SetCachedStateField } from "./types" +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" +import { SearchableSetting } from "./SearchableSetting" + +type RemoteControlSettingsProps = HTMLAttributes & { + remoteControlEnabled?: boolean + remoteControlSocketPath?: string + setCachedStateField: SetCachedStateField<"remoteControlEnabled" | "remoteControlSocketPath"> +} + +export const RemoteControlSettings = ({ + remoteControlEnabled, + remoteControlSocketPath, + setCachedStateField, + ...props +}: RemoteControlSettingsProps) => { + const { t } = useAppTranslation() + return ( +
+ + {t("settings:sections.remoteControl")} + + +
+ + setCachedStateField("remoteControlEnabled", e.target.checked)} + data-testid="remote-control-enabled-checkbox"> + {t("settings:remoteControl.enabled.label")} + +
+ {t("settings:remoteControl.enabled.description")} +
+
+ + {remoteControlEnabled && ( +
+ + + + setCachedStateField("remoteControlSocketPath", (e.target as HTMLInputElement).value) + } + placeholder={t("settings:remoteControl.socketPath.placeholder")} + data-testid="remote-control-socket-path-input"> +
+ {t("settings:remoteControl.socketPath.description")} +
+
+
+ )} +
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/RemoteControlSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/RemoteControlSettings.spec.tsx new file mode 100644 index 0000000000..9cd3a1f3ff --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/RemoteControlSettings.spec.tsx @@ -0,0 +1,71 @@ +import { render, fireEvent, waitFor } from "@testing-library/react" +import { describe, it, expect, vi } from "vitest" + +import { RemoteControlSettings } from "../RemoteControlSettings" + +describe("RemoteControlSettings", () => { + const defaultProps = { + remoteControlEnabled: false, + remoteControlSocketPath: "", + setCachedStateField: vi.fn(), + } + + it("renders the enable checkbox", () => { + const { getByTestId } = render() + expect(getByTestId("remote-control-enabled-checkbox")).toBeTruthy() + }) + + it("does not render the socket path input when disabled", () => { + const { queryByTestId } = render() + expect(queryByTestId("remote-control-socket-path-input")).toBeNull() + }) + + it("reflects the enabled state on the checkbox", () => { + const { getByTestId } = render() + const checkbox = getByTestId("remote-control-enabled-checkbox") as HTMLInputElement + expect(checkbox.checked).toBe(true) + }) + + it("renders the socket path input when enabled", () => { + const { getByTestId } = render( + , + ) + const input = getByTestId("remote-control-socket-path-input") as HTMLInputElement + expect(input.value).toBe("/tmp/foo.sock") + }) + + it("calls setCachedStateField when the enable checkbox is toggled", async () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render( + , + ) + + fireEvent.click(getByTestId("remote-control-enabled-checkbox")) + + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("remoteControlEnabled", true) + }) + }) + + it("calls setCachedStateField when the socket path input changes", async () => { + const setCachedStateField = vi.fn() + const { getByTestId } = render( + , + ) + + const input = getByTestId("remote-control-socket-path-input") + fireEvent.input(input, { target: { value: "/tmp/custom.sock" } }) + + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("remoteControlSocketPath", "/tmp/custom.sock") + }) + }) +}) From fa3330dc927a6da22d470b15641d5176ff544004 Mon Sep 17 00:00:00 2001 From: Naved Date: Sun, 21 Jun 2026 22:05:57 -0700 Subject: [PATCH 5/5] rename bridge package to zoo --- packages/remote-bridge/README.md | 4 ++-- packages/remote-bridge/package.json | 2 +- packages/remote-bridge/scripts/demo.ts | 2 +- packages/remote-bridge/scripts/serve-demo.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/remote-bridge/README.md b/packages/remote-bridge/README.md index a9c1f055cd..a0cfb98a82 100644 --- a/packages/remote-bridge/README.md +++ b/packages/remote-bridge/README.md @@ -30,7 +30,7 @@ The socket path is the same one the extension already reads: the `ROO_CODE_IPC_S ### As a library ```typescript -import { Bridge } from "@roo-code/remote-bridge" +import { Bridge } from "@zoo-code/remote-bridge" const bridge = new Bridge("/tmp/zoo-code.sock") await bridge.connect() @@ -55,7 +55,7 @@ bridge.disconnect() ROO_CODE_IPC_SOCKET_PATH=/tmp/zoo-code.sock code . # 2. From the repo, run the bridge against that socket. -pnpm --filter @roo-code/remote-bridge start -- --socket /tmp/zoo-code.sock --command get-modes +pnpm --filter @zoo-code/remote-bridge start -- --socket /tmp/zoo-code.sock --command get-modes ``` The response event is pretty-printed to stdout; diagnostic logs go to stderr. diff --git a/packages/remote-bridge/package.json b/packages/remote-bridge/package.json index ad23793117..943a09a9f7 100644 --- a/packages/remote-bridge/package.json +++ b/packages/remote-bridge/package.json @@ -1,5 +1,5 @@ { - "name": "@roo-code/remote-bridge", + "name": "@zoo-code/remote-bridge", "description": "Forked Node bridge process that connects Zoo Code's IPC API surface to remote clients (WebRTC in later phases).", "version": "0.0.1", "type": "module", diff --git a/packages/remote-bridge/scripts/demo.ts b/packages/remote-bridge/scripts/demo.ts index 1a1783ac81..874b1ecb42 100644 --- a/packages/remote-bridge/scripts/demo.ts +++ b/packages/remote-bridge/scripts/demo.ts @@ -7,7 +7,7 @@ * the response. This proves the forked node process can talk to the API * surface over a real Unix socket. * - * Run with: pnpm --filter @roo-code/remote-bridge exec tsx scripts/demo.ts + * Run with: pnpm --filter @zoo-code/remote-bridge exec tsx scripts/demo.ts */ import os from "node:os" import path from "node:path" diff --git a/packages/remote-bridge/scripts/serve-demo.ts b/packages/remote-bridge/scripts/serve-demo.ts index 3ba167ae03..349be4c49e 100644 --- a/packages/remote-bridge/scripts/serve-demo.ts +++ b/packages/remote-bridge/scripts/serve-demo.ts @@ -8,7 +8,7 @@ * streams to stdout. This proves the extension's fork path works end-to-end. * * Run from repo root: - * pnpm --filter @roo-code/remote-bridge exec tsx scripts/serve-demo.ts + * pnpm --filter @zoo-code/remote-bridge exec tsx scripts/serve-demo.ts */ import os from "node:os" import path from "node:path"