From bb89ace7701852827fc4a36d9e4c1f596bfc75a2 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:36:44 -0600 Subject: [PATCH 01/36] feat: add Zoo remote control bridge Publish task UI events and apply remote commands when remote control is enabled and the Zoo session is eligible. Fixes #2013. --- .cursor/rules/git-identity.mdc | 44 ++ src/extension.ts | 14 +- src/package.json | 5 + src/package.nls.json | 1 + .../ZooRemoteControlBridge.ts | 477 ++++++++++++++++++ .../ZooRemoteControlClient.ts | 109 ++++ .../__tests__/ZooRemoteControlBridge.spec.ts | 257 ++++++++++ .../__tests__/eligibility.spec.ts | 55 ++ .../zoo-remote-control/eligibility.ts | 24 + src/services/zoo-remote-control/types.ts | 84 +++ 10 files changed, 1069 insertions(+), 1 deletion(-) create mode 100644 .cursor/rules/git-identity.mdc create mode 100644 src/services/zoo-remote-control/ZooRemoteControlBridge.ts create mode 100644 src/services/zoo-remote-control/ZooRemoteControlClient.ts create mode 100644 src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts create mode 100644 src/services/zoo-remote-control/__tests__/eligibility.spec.ts create mode 100644 src/services/zoo-remote-control/eligibility.ts create mode 100644 src/services/zoo-remote-control/types.ts diff --git a/.cursor/rules/git-identity.mdc b/.cursor/rules/git-identity.mdc new file mode 100644 index 0000000000..7a5d7ca1dd --- /dev/null +++ b/.cursor/rules/git-identity.mdc @@ -0,0 +1,44 @@ +--- +description: Confirm JamesRobert20 is the active GitHub/git identity before any git or gh work +alwaysApply: true +--- + +# Git / GitHub identity — JamesRobert20 only + +Before **any** `git` or `gh` operation that creates commits, issues, PRs, comments, or pushes in this repo: + +## Required identity + +- **GitHub login (active `gh` account):** `JamesRobert20` +- **Git author name:** `JamesRobert20` +- **Git author email:** `59908268+JamesRobert20@users.noreply.github.com` (or the matching JamesRobert20 noreply / `jmtendamema@gmail.com` if that is how this machine is configured for JamesRobert20) + +## Preflight (must run first) + +```bash +gh auth status +git config --get user.name +git config --get user.email +``` + +`gh auth status` must show **JamesRobert20** as `Active account: true`. + +## If the wrong account is active + +**Stop.** Do not create issues, PRs, commits, or comments. + +Especially never use the work GitHub account **`jmtendamema`** for this open-source repo (or for any Zoo Code OSS contribution). + +Switch with: + +```bash +gh auth switch --user JamesRobert20 +``` + +Re-check `gh auth status`. Only then continue. + +## Forbidden + +- Using `jmtendamema` (or any non-JamesRobert20 account) for Zoo-Code issues, PRs, reviews, or pushes +- Skipping the preflight because “we already checked earlier in the session” +- Leaving work-account artifacts (issues/comments) without closing or correcting them when discovered diff --git a/src/extension.ts b/src/extension.ts index aa82c5388f..3d2192427b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -39,6 +39,7 @@ import { McpServerManager } from "./services/mcp/McpServerManager" import { CodeIndexManagerRegistry } from "./services/code-index/code-index-manager-registry" import { CodeIndexScope } from "./services/code-index/code-index-scope" import { MdmService } from "./services/mdm/MdmService" +import { ZooRemoteControlBridge } from "./services/zoo-remote-control/ZooRemoteControlBridge" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" import { API } from "./extension/api" @@ -388,7 +389,18 @@ export async function activate(context: vscode.ExtensionContext) { }) const providerFactory = new ClineProviderFactory(context, outputChannel, webviewFocusTracker) - return new API(outputChannel, provider, providerFactory, socketPath, enableLogging) + const api = new API(outputChannel, provider, providerFactory, socketPath, enableLogging) + + context.subscriptions.push( + new ZooRemoteControlBridge({ + api, + provider, + context, + log: (message) => outputChannel.appendLine(message), + }), + ) + + return api } // This method is called when your extension is deactivated. diff --git a/src/package.json b/src/package.json index 353fc40c40..f256316889 100644 --- a/src/package.json +++ b/src/package.json @@ -297,6 +297,11 @@ "configuration": { "title": "%configuration.title%", "properties": { + "zoo-code.remoteControl.enabled": { + "type": "boolean", + "default": false, + "description": "%settings.remoteControl.enabled.description%" + }, "zoo-code.allowedCommands": { "type": "array", "items": { diff --git a/src/package.nls.json b/src/package.nls.json index fe10839819..74d19f91fc 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -26,6 +26,7 @@ "command.showRipgrepDiagnostic.title": "Show Ripgrep Diagnostic", "command.toggleAutoApprove.title": "Toggle Auto-Approve", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", "commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.", "commands.commandExecutionTimeout.description": "Maximum time in seconds to wait for command execution to complete before timing out (0 = no timeout, 1-600s, default: 0s)", diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts new file mode 100644 index 0000000000..dd02c5b7e7 --- /dev/null +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -0,0 +1,477 @@ +import * as vscode from "vscode" + +import { + providerIdentifiers, + RooCodeEventName, + type ClineMessage, + type ProviderSettings, + type RooCodeAPI, +} from "@roo-code/types" + +import type { ClineProvider } from "../../core/webview/ClineProvider" +import { Package } from "../../shared/package" +import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../zoo-code-auth" +import { isZooRemoteControlEligible } from "./eligibility" +import { ZooRemoteControlClient } from "./ZooRemoteControlClient" +import type { ZooRemoteCommand, ZooRemoteEvent } from "./types" + +const PRESENCE_INTERVAL_MS = 15_000 +const COMMAND_POLL_INTERVAL_MS = 2_000 +const EVENT_FLUSH_INTERVAL_MS = 100 +const PARTIAL_EVENT_MIN_INTERVAL_MS = 75 +const UNAVAILABLE_BACKOFF_MS = 60_000 +const INSTANCE_ID_STATE_KEY = "zooRemoteControl.instanceId" + +export type ZooRemoteControlBridgeDeps = { + api: Pick + provider: ClineProvider + context: vscode.ExtensionContext + log?: (message: string) => void + client?: ZooRemoteControlClient + now?: () => number +} + +/** Publishes task UI events to Zoo and applies inbound remote commands when eligible. */ +export class ZooRemoteControlBridge implements vscode.Disposable { + private readonly api: ZooRemoteControlBridgeDeps["api"] + private readonly provider: ClineProvider + private readonly context: vscode.ExtensionContext + private readonly log: (message: string) => void + private readonly client: ZooRemoteControlClient + private readonly now: () => number + private readonly instanceId: string + + private disposed = false + private active = false + private serverAvailable = true + private unavailableUntil = 0 + private eventSeq = 0 + private pendingEvents: ZooRemoteEvent[] = [] + private lastPartialFlushAt = 0 + + private presenceTimer: ReturnType | undefined + private commandTimer: ReturnType | undefined + private flushTimer: ReturnType | undefined + private readonly disposables: vscode.Disposable[] = [] + + private readonly onMessage = (payload: { taskId: string; action?: string; message: ClineMessage }) => { + void this.handleMessageEvent(payload) + } + + private readonly onTaskInteractive = (taskId: string) => { + this.enqueueEvent({ type: "task_interactive", taskId, timestamp: this.now() }) + } + + private readonly onTaskIdle = (taskId: string) => { + this.enqueueEvent({ type: "task_idle", taskId, timestamp: this.now() }) + } + + private readonly onTaskResumable = (taskId: string) => { + this.enqueueEvent({ type: "task_resumable", taskId, timestamp: this.now() }) + } + + private readonly onTaskCompleted = (taskId: string) => { + this.enqueueEvent({ type: "task_completed", taskId, timestamp: this.now() }) + } + + private readonly onTaskAborted = (taskId: string) => { + this.enqueueEvent({ type: "task_aborted", taskId, timestamp: this.now() }) + } + + private readonly onModeSwitched = (taskId: string, mode: string) => { + this.enqueueEvent({ type: "mode_switched", taskId, mode, timestamp: this.now() }) + } + + private readonly onProviderProfileChanged = () => { + void this.syncActiveState() + } + + constructor(deps: ZooRemoteControlBridgeDeps) { + this.api = deps.api + this.provider = deps.provider + this.context = deps.context + this.log = deps.log ?? (() => {}) + this.now = deps.now ?? (() => Date.now()) + this.instanceId = this.resolveInstanceId() + this.client = + deps.client ?? + new ZooRemoteControlClient({ + getBaseUrl: getZooCodeBaseUrl, + getSessionToken: getCachedZooCodeToken, + log: this.log, + }) + + this.disposables.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("zoo-code.remoteControl.enabled")) { + void this.syncActiveState() + } + }), + ) + + this.provider.on(RooCodeEventName.ProviderProfileChanged, this.onProviderProfileChanged) + + if (this.context.secrets?.onDidChange) { + this.disposables.push( + this.context.secrets.onDidChange((event) => { + if (event.key === "zoo-code-session-token") { + void this.syncActiveState() + } + }), + ) + } + + void this.syncActiveState() + } + + public dispose(): void { + this.disposed = true + void this.deactivate({ notifyOffline: true }) + this.provider.off(RooCodeEventName.ProviderProfileChanged, this.onProviderProfileChanged) + for (const disposable of this.disposables) { + disposable.dispose() + } + this.disposables.length = 0 + } + + /** Test helper */ + public isActive(): boolean { + return this.active + } + + /** Test helper */ + public getInstanceId(): string { + return this.instanceId + } + + private resolveInstanceId(): string { + const existing = this.context.globalState.get(INSTANCE_ID_STATE_KEY) + if (existing) { + return existing + } + + const generated = + typeof vscode.env.sessionId === "string" && vscode.env.sessionId.length > 0 + ? vscode.env.sessionId + : `zoo-${this.now()}-${Math.random().toString(36).slice(2, 10)}` + + void this.context.globalState.update(INSTANCE_ID_STATE_KEY, generated) + return generated + } + + private isSettingEnabled(): boolean { + return vscode.workspace.getConfiguration(Package.name).get("remoteControl.enabled", false) + } + + private getApiProvider(): string | undefined { + const settings: ProviderSettings = this.provider.contextProxy.getProviderSettings() + return settings.apiProvider + } + + private evaluateEligible(): boolean { + return isZooRemoteControlEligible({ + remoteControlEnabled: this.isSettingEnabled(), + apiProvider: this.getApiProvider(), + sessionToken: getCachedZooCodeToken() || undefined, + }) + } + + private async syncActiveState(): Promise { + if (this.disposed) { + return + } + + const eligible = this.evaluateEligible() + if (eligible && !this.active) { + await this.activate() + } else if (!eligible && this.active) { + await this.deactivate({ notifyOffline: true }) + } else if (eligible && this.active) { + await this.publishPresence(true) + } + } + + private async activate(): Promise { + if (this.active || this.disposed) { + return + } + + this.active = true + this.serverAvailable = true + this.unavailableUntil = 0 + this.bindApiListeners(true) + this.presenceTimer = setInterval(() => { + void this.publishPresence(true) + }, PRESENCE_INTERVAL_MS) + this.commandTimer = setInterval(() => { + void this.pollCommands() + }, COMMAND_POLL_INTERVAL_MS) + this.flushTimer = setInterval(() => { + void this.flushEvents() + }, EVENT_FLUSH_INTERVAL_MS) + + this.log("[ZooRemoteControl] enabled") + await this.publishPresence(true) + } + + private async deactivate({ notifyOffline }: { notifyOffline: boolean }): Promise { + if (!this.active && !notifyOffline) { + this.clearTimers() + this.bindApiListeners(false) + return + } + + this.active = false + this.clearTimers() + this.bindApiListeners(false) + this.pendingEvents = [] + + if (notifyOffline && this.evaluateEligibleForOfflineNotify()) { + await this.publishPresence(false) + } + + this.log("[ZooRemoteControl] disabled") + } + + private evaluateEligibleForOfflineNotify(): boolean { + // Only notify offline while a Zoo session can still authenticate the call. + return Boolean(getCachedZooCodeToken()) && this.getApiProvider() === providerIdentifiers.zooGateway + } + + private clearTimers(): void { + if (this.presenceTimer) { + clearInterval(this.presenceTimer) + this.presenceTimer = undefined + } + if (this.commandTimer) { + clearInterval(this.commandTimer) + this.commandTimer = undefined + } + if (this.flushTimer) { + clearInterval(this.flushTimer) + this.flushTimer = undefined + } + } + + private bindApiListeners(bind: boolean): void { + const method = bind ? "on" : "off" + this.api[method](RooCodeEventName.Message, this.onMessage) + this.api[method](RooCodeEventName.TaskInteractive, this.onTaskInteractive) + this.api[method](RooCodeEventName.TaskIdle, this.onTaskIdle) + this.api[method](RooCodeEventName.TaskResumable, this.onTaskResumable) + this.api[method](RooCodeEventName.TaskCompleted, this.onTaskCompleted) + this.api[method](RooCodeEventName.TaskAborted, this.onTaskAborted) + this.api[method](RooCodeEventName.TaskModeSwitched, this.onModeSwitched) + } + + private async handleMessageEvent(payload: { + taskId: string + action?: string + message: ClineMessage + }): Promise { + if (!this.active) { + return + } + + const isPartial = payload.message.partial === true + if (isPartial) { + const elapsed = this.now() - this.lastPartialFlushAt + if (elapsed < PARTIAL_EVENT_MIN_INTERVAL_MS) { + return + } + this.lastPartialFlushAt = this.now() + } + + this.enqueueEvent({ + type: "message", + taskId: payload.taskId, + message: payload.message, + timestamp: this.now(), + }) + } + + private enqueueEvent(event: Omit): void { + if (!this.active) { + return + } + + this.pendingEvents.push({ ...event, seq: this.eventSeq++ }) + } + + private markUnavailable(): void { + this.serverAvailable = false + this.unavailableUntil = this.now() + UNAVAILABLE_BACKOFF_MS + } + + private canCallServer(): boolean { + if (!this.active) { + return false + } + if (this.serverAvailable) { + return true + } + return this.now() >= this.unavailableUntil + } + + private async publishPresence(online: boolean): Promise { + if (!this.canCallServer() && online) { + return + } + + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + const taskId = this.provider.getCurrentTask()?.taskId + const result = await this.client.postPresence({ + instanceId: this.instanceId, + apiProvider: this.getApiProvider() ?? providerIdentifiers.zooGateway, + workspacePath, + taskId, + online, + editorName: vscode.env.appName, + extensionVersion: Package.version, + }) + + if (result.status === 404) { + this.markUnavailable() + return + } + + if (result.ok) { + this.serverAvailable = true + } + } + + private async flushEvents(): Promise { + if (!this.active || this.pendingEvents.length === 0 || !this.canCallServer()) { + return + } + + const batch = this.pendingEvents.splice(0, this.pendingEvents.length) + const result = await this.client.postEvents(batch) + + if (result.status === 404) { + this.markUnavailable() + // Drop batch while the public endpoint is not available yet. + return + } + + if (!result.ok) { + // Re-queue failed non-404 batches at the front (bounded). + this.pendingEvents = [...batch.slice(-50), ...this.pendingEvents].slice(0, 200) + return + } + + this.serverAvailable = true + } + + private async pollCommands(): Promise { + if (!this.active || !this.canCallServer()) { + return + } + + if (!this.evaluateEligible()) { + await this.deactivate({ notifyOffline: true }) + return + } + + const result = await this.client.getCommands(this.instanceId) + + if (result.status === 404) { + this.markUnavailable() + return + } + + if (!result.ok || !result.body) { + return + } + + this.serverAvailable = true + + for (const command of result.body.commands) { + await this.executeCommand(command) + } + } + + private async executeCommand(command: ZooRemoteCommand): Promise { + if (!this.evaluateEligible()) { + await this.client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "not_eligible", + }) + return + } + + try { + switch (command.type) { + case "message": + await this.api.sendMessage(command.payload?.text, command.payload?.images) + break + case "approve_ask": { + const task = this.provider.getCurrentTask() + if (!task) { + await this.client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "no_current_task", + }) + return + } + task.approveAsk({ text: command.payload?.text, images: command.payload?.images }) + break + } + case "deny_ask": { + const task = this.provider.getCurrentTask() + if (!task) { + await this.client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "no_current_task", + }) + return + } + task.denyAsk({ text: command.payload?.text, images: command.payload?.images }) + break + } + case "cancel_task": + await this.api.cancelCurrentTask() + break + case "resume_task": + if (!command.taskId) { + await this.client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "missing_task_id", + }) + return + } + await this.api.resumeTask(command.taskId) + break + case "start_task": + await this.api.startNewTask({ + text: command.payload?.text, + images: command.payload?.images, + }) + break + default: { + const _exhaustive: never = command.type + await this.client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "unknown_command", + message: String(_exhaustive), + }) + return + } + } + + await this.client.ackCommand({ commandId: command.commandId, status: "acked" }) + } catch (error) { + await this.client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "execution_failed", + message: error instanceof Error ? error.message : String(error), + }) + } + } +} diff --git a/src/services/zoo-remote-control/ZooRemoteControlClient.ts b/src/services/zoo-remote-control/ZooRemoteControlClient.ts new file mode 100644 index 0000000000..3bb2486d3f --- /dev/null +++ b/src/services/zoo-remote-control/ZooRemoteControlClient.ts @@ -0,0 +1,109 @@ +import { + zooRemoteCommandAckSchema, + zooRemoteCommandsResponseSchema, + type ZooRemoteCommandAck, + type ZooRemoteCommandsResponse, + type ZooRemoteEvent, + type ZooRemotePresence, +} from "./types" + +export type ZooRemoteControlClientOptions = { + getBaseUrl: () => string + getSessionToken: () => string + fetchFn?: typeof fetch + log?: (message: string) => void +} + +/** + * Thin HTTP client for /api/extension/remote/* using the Zoo session token. + * Callers treat 404 as "server not ready" and back off. + */ +export class ZooRemoteControlClient { + private readonly getBaseUrl: () => string + private readonly getSessionToken: () => string + private readonly fetchFn: typeof fetch + private readonly log: (message: string) => void + + constructor(options: ZooRemoteControlClientOptions) { + this.getBaseUrl = options.getBaseUrl + this.getSessionToken = options.getSessionToken + this.fetchFn = options.fetchFn ?? fetch + this.log = options.log ?? (() => {}) + } + + async postPresence(presence: ZooRemotePresence): Promise<{ ok: boolean; status: number }> { + return this.request("POST", "/api/extension/remote/presence", presence) + } + + async postEvents(events: ZooRemoteEvent[]): Promise<{ ok: boolean; status: number }> { + return this.request("POST", "/api/extension/remote/events", { events }) + } + + async getCommands(instanceId: string): Promise<{ + ok: boolean + status: number + body?: ZooRemoteCommandsResponse + }> { + const result = await this.requestJson( + "GET", + `/api/extension/remote/commands?instanceId=${encodeURIComponent(instanceId)}`, + undefined, + zooRemoteCommandsResponseSchema, + ) + return result + } + + async ackCommand(ack: ZooRemoteCommandAck): Promise<{ ok: boolean; status: number }> { + return this.request("POST", `/api/extension/remote/commands/${encodeURIComponent(ack.commandId)}/ack`, ack) + } + + private async request(method: string, path: string, body?: unknown): Promise<{ ok: boolean; status: number }> { + const result = await this.requestJson(method, path, body) + return { ok: result.ok, status: result.status } + } + + private async requestJson( + method: string, + path: string, + body: unknown | undefined, + schema?: { parse: (data: unknown) => T }, + ): Promise<{ ok: boolean; status: number; body?: T }> { + const token = this.getSessionToken() + if (!token) { + return { ok: false, status: 401 } + } + + const url = `${this.getBaseUrl().replace(/\/$/, "")}${path}` + + try { + const response = await this.fetchFn(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + + if (!response.ok) { + if (response.status !== 404) { + this.log(`[ZooRemoteControl] ${method} ${path} -> ${response.status}`) + } + return { ok: false, status: response.status } + } + + if (!schema) { + return { ok: true, status: response.status } + } + + const json: unknown = await response.json() + return { ok: true, status: response.status, body: schema.parse(json) } + } catch (error) { + this.log( + `[ZooRemoteControl] ${method} ${path} failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return { ok: false, status: 0 } + } + } +} diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts new file mode 100644 index 0000000000..5dc56f0e99 --- /dev/null +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -0,0 +1,257 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { EventEmitter } from "events" + +import { providerIdentifiers, RooCodeEventName } from "@roo-code/types" + +import { ZooRemoteControlBridge } from "../ZooRemoteControlBridge" +import type { ZooRemoteControlClient } from "../ZooRemoteControlClient" + +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn((_key: string, defaultValue?: boolean) => defaultValue), + })), + onDidChangeConfiguration: vi.fn(() => ({ dispose: vi.fn() })), + workspaceFolders: [{ uri: { fsPath: "/tmp/project" } }], + }, + env: { + sessionId: "test-session-id", + appName: "Code", + }, +})) + +vi.mock("../../zoo-code-auth", () => ({ + getCachedZooCodeToken: vi.fn(() => ""), + getZooCodeBaseUrl: vi.fn(() => "https://www.zoocode.dev"), +})) + +import * as vscode from "vscode" +import { getCachedZooCodeToken } from "../../zoo-code-auth" + +describe("ZooRemoteControlBridge", () => { + let api: EventEmitter & { + sendMessage: ReturnType + cancelCurrentTask: ReturnType + resumeTask: ReturnType + startNewTask: ReturnType + } + let provider: EventEmitter & { + contextProxy: { getProviderSettings: ReturnType } + getCurrentTask: ReturnType + } + let client: { + postPresence: ReturnType + postEvents: ReturnType + getCommands: ReturnType + ackCommand: ReturnType + } + let context: { + globalState: { get: ReturnType; update: ReturnType } + secrets: { onDidChange: ReturnType } + } + let getConfigValue: (key: string, defaultValue?: boolean) => boolean + + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + + getConfigValue = (_key, defaultValue) => defaultValue ?? false + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: boolean) => getConfigValue(key, defaultValue)), + } as never) + + vi.mocked(getCachedZooCodeToken).mockReturnValue("") + + api = Object.assign(new EventEmitter(), { + sendMessage: vi.fn().mockResolvedValue(undefined), + cancelCurrentTask: vi.fn().mockResolvedValue(undefined), + resumeTask: vi.fn().mockResolvedValue(undefined), + startNewTask: vi.fn().mockResolvedValue("task-1"), + }) + + provider = Object.assign(new EventEmitter(), { + contextProxy: { + getProviderSettings: vi.fn(() => ({ apiProvider: providerIdentifiers.openrouter })), + }, + getCurrentTask: vi.fn(() => undefined), + }) + + client = { + postPresence: vi.fn().mockResolvedValue({ ok: true, status: 200 }), + postEvents: vi.fn().mockResolvedValue({ ok: true, status: 200 }), + getCommands: vi.fn().mockResolvedValue({ ok: true, status: 200, body: { commands: [] } }), + ackCommand: vi.fn().mockResolvedValue({ ok: true, status: 200 }), + } + + context = { + globalState: { + get: vi.fn(() => "instance-1"), + update: vi.fn().mockResolvedValue(undefined), + }, + secrets: { + onDidChange: vi.fn(() => ({ dispose: vi.fn() })), + }, + } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function createBridge() { + return new ZooRemoteControlBridge({ + api: api as never, + provider: provider as never, + context: context as never, + client: client as unknown as ZooRemoteControlClient, + now: () => Date.now(), + }) + } + + it("stays inactive when remote control is disabled", async () => { + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(false) + expect(client.postPresence).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("activates when opted in with an eligible Zoo session", async () => { + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + + expect(bridge.isActive()).toBe(true) + expect(client.postPresence).toHaveBeenCalledWith( + expect.objectContaining({ + instanceId: "instance-1", + apiProvider: providerIdentifiers.zooGateway, + online: true, + }), + ) + bridge.dispose() + }) + + it("does not activate when the provider is not eligible", async () => { + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.anthropic, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) + + it("executes a remote message command and acks success", async () => { + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + client.getCommands.mockResolvedValue({ + ok: true, + status: 200, + body: { + commands: [ + { + commandId: "cmd-1", + type: "message", + payload: { text: "hello from remote" }, + }, + ], + }, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(2_000) + + expect(api.sendMessage).toHaveBeenCalledWith("hello from remote", undefined) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "cmd-1", status: "acked" }) + bridge.dispose() + }) + + it("backs off when the remote endpoints return 404", async () => { + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + client.postPresence.mockResolvedValue({ ok: false, status: 404 }) + client.getCommands.mockResolvedValue({ ok: false, status: 404 }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(true) + + client.postPresence.mockClear() + await vi.advanceTimersByTimeAsync(15_000) + // Still in 404 backoff window — presence should not fire again yet. + expect(client.postPresence).not.toHaveBeenCalled() + + bridge.dispose() + }) + + it("publishes non-partial message events while active", async () => { + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + action: "created", + message: { ts: 1, type: "say", say: "text", text: "hi", partial: false }, + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(client.postEvents).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + type: "message", + taskId: "task-1", + message: expect.objectContaining({ text: "hi" }), + }), + ]), + ) + bridge.dispose() + }) + + it("deactivates when the session is no longer eligible", async () => { + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(true) + + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.openrouter, + }) + provider.emit(RooCodeEventName.ProviderProfileChanged, { + name: "OpenRouter", + provider: providerIdentifiers.openrouter, + }) + await vi.advanceTimersByTimeAsync(0) + + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) +}) diff --git a/src/services/zoo-remote-control/__tests__/eligibility.spec.ts b/src/services/zoo-remote-control/__tests__/eligibility.spec.ts new file mode 100644 index 0000000000..2173ae60a3 --- /dev/null +++ b/src/services/zoo-remote-control/__tests__/eligibility.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest" + +import { providerIdentifiers } from "@roo-code/types" + +import { isZooRemoteControlEligible } from "../eligibility" + +describe("isZooRemoteControlEligible", () => { + it("returns true when opted in with an eligible Zoo session", () => { + expect( + isZooRemoteControlEligible({ + remoteControlEnabled: true, + apiProvider: providerIdentifiers.zooGateway, + sessionToken: "zoo_ext_test", + }), + ).toBe(true) + }) + + it("returns false when the setting is off", () => { + expect( + isZooRemoteControlEligible({ + remoteControlEnabled: false, + apiProvider: providerIdentifiers.zooGateway, + sessionToken: "zoo_ext_test", + }), + ).toBe(false) + }) + + it("returns false when the active provider is not eligible", () => { + expect( + isZooRemoteControlEligible({ + remoteControlEnabled: true, + apiProvider: providerIdentifiers.openrouter, + sessionToken: "zoo_ext_test", + }), + ).toBe(false) + }) + + it("returns false when the session token is missing", () => { + expect( + isZooRemoteControlEligible({ + remoteControlEnabled: true, + apiProvider: providerIdentifiers.zooGateway, + sessionToken: undefined, + }), + ).toBe(false) + + expect( + isZooRemoteControlEligible({ + remoteControlEnabled: true, + apiProvider: providerIdentifiers.zooGateway, + sessionToken: "", + }), + ).toBe(false) + }) +}) diff --git a/src/services/zoo-remote-control/eligibility.ts b/src/services/zoo-remote-control/eligibility.ts new file mode 100644 index 0000000000..f7985a827b --- /dev/null +++ b/src/services/zoo-remote-control/eligibility.ts @@ -0,0 +1,24 @@ +import { providerIdentifiers } from "@roo-code/types" + +export type ZooRemoteControlEligibilityInput = { + remoteControlEnabled: boolean + apiProvider: string | undefined + sessionToken: string | undefined +} + +/** True when remote control is opted in and the session is eligible. */ +export function isZooRemoteControlEligible({ + remoteControlEnabled, + apiProvider, + sessionToken, +}: ZooRemoteControlEligibilityInput): boolean { + if (!remoteControlEnabled) { + return false + } + + if (apiProvider !== providerIdentifiers.zooGateway) { + return false + } + + return Boolean(sessionToken && sessionToken.length > 0) +} diff --git a/src/services/zoo-remote-control/types.ts b/src/services/zoo-remote-control/types.ts new file mode 100644 index 0000000000..15ec60fa20 --- /dev/null +++ b/src/services/zoo-remote-control/types.ts @@ -0,0 +1,84 @@ +import { z } from "zod" + +import { clineMessageSchema } from "@roo-code/types" + +/** + * Public wire types for Zoo account remote task control. + * Paths live under /api/extension/remote/* and use the extension session token. + */ + +export const zooRemoteCommandTypeSchema = z.enum([ + "message", + "approve_ask", + "deny_ask", + "cancel_task", + "resume_task", + "start_task", +]) + +export type ZooRemoteCommandType = z.infer + +export const zooRemoteCommandSchema = z.object({ + commandId: z.string().min(1), + type: zooRemoteCommandTypeSchema, + taskId: z.string().optional(), + payload: z + .object({ + text: z.string().optional(), + images: z.array(z.string()).optional(), + }) + .optional(), + timestamp: z.number().optional(), +}) + +export type ZooRemoteCommand = z.infer + +export const zooRemoteCommandsResponseSchema = z.object({ + commands: z.array(zooRemoteCommandSchema).default([]), +}) + +export type ZooRemoteCommandsResponse = z.infer + +export const zooRemoteEventTypeSchema = z.enum([ + "message", + "task_interactive", + "task_idle", + "task_resumable", + "task_completed", + "task_aborted", + "mode_switched", +]) + +export type ZooRemoteEventType = z.infer + +export const zooRemoteEventSchema = z.object({ + type: zooRemoteEventTypeSchema, + taskId: z.string().min(1), + message: clineMessageSchema.optional(), + mode: z.string().optional(), + seq: z.number().int().nonnegative().optional(), + timestamp: z.number(), +}) + +export type ZooRemoteEvent = z.infer + +export const zooRemotePresenceSchema = z.object({ + instanceId: z.string().min(1), + apiProvider: z.string().min(1), + workspacePath: z.string().optional(), + taskId: z.string().optional(), + online: z.boolean(), + editorName: z.string().optional(), + extensionVersion: z.string().optional(), +}) + +export type ZooRemotePresence = z.infer + +export const zooRemoteCommandAckSchema = z.object({ + commandId: z.string().min(1), + status: z.enum(["acked", "failed"]), + errorCode: z.string().optional(), + message: z.string().optional(), +}) + +export type ZooRemoteCommandAck = z.infer From adcec9e434ebc4868f30beed2edb7a729c9f0d44 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:49:17 -0600 Subject: [PATCH 02/36] fix: harden remote control bridge for CI Add locale strings for the remote control setting and guard vscode APIs used during extension tests. Fixes #2013. --- src/__tests__/extension.spec.ts | 1 + src/package.nls.ca.json | 1 + src/package.nls.de.json | 1 + src/package.nls.es.json | 1 + src/package.nls.fr.json | 1 + src/package.nls.hi.json | 1 + src/package.nls.id.json | 1 + src/package.nls.it.json | 1 + src/package.nls.ja.json | 1 + src/package.nls.ko.json | 1 + src/package.nls.nl.json | 1 + src/package.nls.pl.json | 1 + src/package.nls.pt-BR.json | 1 + src/package.nls.ru.json | 1 + src/package.nls.tr.json | 1 + src/package.nls.vi.json | 1 + src/package.nls.zh-CN.json | 1 + src/package.nls.zh-TW.json | 1 + .../ZooRemoteControlBridge.ts | 58 ++++++++++++------- 19 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 889f780c9d..4e12bac398 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -30,6 +30,7 @@ vi.mock("vscode", () => ({ dispose: vi.fn(), }), onDidChangeWorkspaceFolders: vi.fn(), + onDidChangeConfiguration: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, languages: { registerCodeActionsProvider: vi.fn(), diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index b1397adf71..0ebcf16a66 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -26,6 +26,7 @@ "command.settings.title": "Configuració", "command.documentation.title": "Documentació", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Ordres que es poden executar automàticament quan 'Aprova sempre les operacions d'execució' està activat", "commands.deniedCommands.description": "Prefixos d'ordres que seran automàticament denegats sense demanar aprovació. En cas de conflictes amb ordres permeses, la coincidència de prefix més llarga té prioritat. Afegeix * per denegar totes les ordres.", "commands.commandExecutionTimeout.description": "Temps màxim en segons per esperar que l'execució de l'ordre es completi abans d'esgotar el temps (0 = sense temps límit, 1-600s, per defecte: 0s)", diff --git a/src/package.nls.de.json b/src/package.nls.de.json index b4cd8440ab..41853893ee 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -26,6 +26,7 @@ "command.settings.title": "Einstellungen", "command.documentation.title": "Dokumentation", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Befehle, die automatisch ausgeführt werden können, wenn 'Ausführungsoperationen immer genehmigen' aktiviert ist", "commands.deniedCommands.description": "Befehlspräfixe, die automatisch abgelehnt werden, ohne nach Genehmigung zu fragen. Bei Konflikten mit erlaubten Befehlen hat die längste Präfix-Übereinstimmung Vorrang. Füge * hinzu, um alle Befehle abzulehnen.", "commands.commandExecutionTimeout.description": "Maximale Zeit in Sekunden, die auf den Abschluss der Befehlsausführung gewartet wird, bevor ein Timeout auftritt (0 = kein Timeout, 1-600s, Standard: 0s)", diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 1d86a70980..8f502b83c8 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -26,6 +26,7 @@ "command.settings.title": "Configuración", "command.documentation.title": "Documentación", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Comandos que pueden ejecutarse automáticamente cuando 'Aprobar siempre operaciones de ejecución' está activado", "commands.deniedCommands.description": "Prefijos de comandos que serán automáticamente denegados sin solicitar aprobación. En caso de conflictos con comandos permitidos, la coincidencia de prefijo más larga tiene prioridad. Añade * para denegar todos los comandos.", "commands.commandExecutionTimeout.description": "Tiempo máximo en segundos para esperar que se complete la ejecución del comando antes de que expire (0 = sin tiempo límite, 1-600s, predeterminado: 0s)", diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 85207df975..6770ee63ac 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -26,6 +26,7 @@ "command.settings.title": "Paramètres", "command.documentation.title": "Documentation", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Commandes pouvant être exécutées automatiquement lorsque 'Toujours approuver les opérations d'exécution' est activé", "commands.deniedCommands.description": "Préfixes de commandes qui seront automatiquement refusés sans demander d'approbation. En cas de conflit avec les commandes autorisées, la correspondance de préfixe la plus longue a la priorité. Ajouter * pour refuser toutes les commandes.", "commands.commandExecutionTimeout.description": "Temps maximum en secondes pour attendre que l'exécution de la commande se termine avant expiration (0 = pas de délai, 1-600s, défaut : 0s)", diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index f4f283bb65..3cd60be5c2 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -26,6 +26,7 @@ "command.settings.title": "सेटिंग्स", "command.documentation.title": "दस्तावेज़ीकरण", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "वे कमांड जो स्वचालित रूप से निष्पादित की जा सकती हैं जब 'हमेशा निष्पादन संचालन को स्वीकृत करें' सक्रिय हो", "commands.deniedCommands.description": "कमांड प्रीफिक्स जो स्वचालित रूप से अस्वीकार कर दिए जाएंगे बिना अनुमोदन मांगे। अनुमतित कमांड के साथ संघर्ष की स्थिति में, सबसे लंबा प्रीफिक्स मैच प्राथमिकता लेता है। सभी कमांड को अस्वीकार करने के लिए * जोड़ें।", "commands.commandExecutionTimeout.description": "कमांड निष्पादन पूरा होने का इंतजार करने के लिए अधिकतम समय सेकंड में, समय समाप्त होने से पहले (0 = कोई समय सीमा नहीं, 1-600s, डिफ़ॉल्ट: 0s)", diff --git a/src/package.nls.id.json b/src/package.nls.id.json index c49e54a34e..7ab3ab9d8b 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -26,6 +26,7 @@ "command.showRipgrepDiagnostic.title": "Tampilkan Diagnostik Ripgrep", "command.toggleAutoApprove.title": "Alihkan Persetujuan Otomatis", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Perintah yang dapat dijalankan secara otomatis ketika 'Selalu setujui operasi eksekusi' diaktifkan", "commands.deniedCommands.description": "Awalan perintah yang akan otomatis ditolak tanpa meminta persetujuan. Jika terjadi konflik dengan perintah yang diizinkan, pencocokan awalan terpanjang akan diprioritaskan. Tambahkan * untuk menolak semua perintah.", "commands.commandExecutionTimeout.description": "Waktu maksimum dalam detik untuk menunggu eksekusi perintah selesai sebelum timeout (0 = tanpa timeout, 1-600s, default: 0s)", diff --git a/src/package.nls.it.json b/src/package.nls.it.json index 2a4b95f954..63d5b2f757 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -26,6 +26,7 @@ "command.settings.title": "Impostazioni", "command.documentation.title": "Documentazione", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Comandi che possono essere eseguiti automaticamente quando 'Approva sempre le operazioni di esecuzione' è attivato", "commands.deniedCommands.description": "Prefissi di comandi che verranno automaticamente rifiutati senza richiedere approvazione. In caso di conflitti con comandi consentiti, la corrispondenza del prefisso più lungo ha la precedenza. Aggiungi * per rifiutare tutti i comandi.", "commands.commandExecutionTimeout.description": "Tempo massimo in secondi per attendere il completamento dell'esecuzione del comando prima del timeout (0 = nessun timeout, 1-600s, predefinito: 0s)", diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index 0f3f4a3cee..fc5566330b 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -26,6 +26,7 @@ "command.showRipgrepDiagnostic.title": "Ripgrep 診断を表示", "command.toggleAutoApprove.title": "自動承認を切替", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "'常に実行操作を承認する'が有効な場合に自動実行できるコマンド", "commands.deniedCommands.description": "承認を求めずに自動的に拒否されるコマンドプレフィックス。許可されたコマンドとの競合がある場合、最長プレフィックスマッチが優先されます。すべてのコマンドを拒否するには * を追加してください。", "commands.commandExecutionTimeout.description": "コマンド実行の完了を待つ最大時間(秒)、タイムアウトまで(0 = タイムアウトなし、1-600秒、デフォルト: 0秒)", diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index 99a064cd6c..2eb08731dc 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -26,6 +26,7 @@ "command.settings.title": "설정", "command.documentation.title": "문서", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "'항상 실행 작업 승인' 이 활성화되어 있을 때 자동으로 실행할 수 있는 명령어", "commands.deniedCommands.description": "승인을 요청하지 않고 자동으로 거부될 명령어 접두사. 허용된 명령어와 충돌하는 경우 가장 긴 접두사 일치가 우선됩니다. 모든 명령어를 거부하려면 *를 추가하세요.", "commands.commandExecutionTimeout.description": "명령어 실행이 완료되기를 기다리는 최대 시간(초), 타임아웃 전까지 (0 = 타임아웃 없음, 1-600초, 기본값: 0초)", diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 27948f933a..0ea8598203 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -26,6 +26,7 @@ "command.showRipgrepDiagnostic.title": "Ripgrep-diagnose weergeven", "command.toggleAutoApprove.title": "Auto-Goedkeuring Schakelen", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Commando's die automatisch kunnen worden uitgevoerd wanneer 'Altijd goedkeuren uitvoerbewerkingen' is ingeschakeld", "commands.deniedCommands.description": "Commando-prefixen die automatisch worden geweigerd zonder om goedkeuring te vragen. Bij conflicten met toegestane commando's heeft de langste prefix-match voorrang. Voeg * toe om alle commando's te weigeren.", "commands.commandExecutionTimeout.description": "Maximale tijd in seconden om te wachten tot commando-uitvoering voltooid is voordat er een timeout optreedt (0 = geen timeout, 1-600s, standaard: 0s)", diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 8fb8b6eb83..6732551d08 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -26,6 +26,7 @@ "command.settings.title": "Ustawienia", "command.documentation.title": "Dokumentacja", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Polecenia, które mogą być wykonywane automatycznie, gdy włączona jest opcja 'Zawsze zatwierdzaj operacje wykonania'", "commands.deniedCommands.description": "Prefiksy poleceń, które będą automatycznie odrzucane bez pytania o zatwierdzenie. W przypadku konfliktów z dozwolonymi poleceniami, najdłuższe dopasowanie prefiksu ma pierwszeństwo. Dodaj * aby odrzucić wszystkie polecenia.", "commands.commandExecutionTimeout.description": "Maksymalny czas w sekundach oczekiwania na zakończenie wykonania polecenia przed przekroczeniem limitu czasu (0 = brak limitu czasu, 1-600s, domyślnie: 0s)", diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index b4cae4644b..e15c2c6ca8 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -26,6 +26,7 @@ "command.settings.title": "Configurações", "command.documentation.title": "Documentação", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Comandos que podem ser executados automaticamente quando 'Sempre aprovar operações de execução' está ativado", "commands.deniedCommands.description": "Prefixos de comandos que serão automaticamente negados sem solicitar aprovação. Em caso de conflitos com comandos permitidos, a correspondência de prefixo mais longa tem precedência. Adicione * para negar todos os comandos.", "commands.commandExecutionTimeout.description": "Tempo máximo em segundos para aguardar a conclusão da execução do comando antes do timeout (0 = sem timeout, 1-600s, padrão: 0s)", diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index 0e61002848..1b9662bb54 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -26,6 +26,7 @@ "command.showRipgrepDiagnostic.title": "Показать диагностику Ripgrep", "command.toggleAutoApprove.title": "Переключить Авто-Подтверждение", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Команды, которые могут быть автоматически выполнены, когда включена опция 'Всегда подтверждать операции выполнения'", "commands.deniedCommands.description": "Префиксы команд, которые будут автоматически отклонены без запроса подтверждения. В случае конфликтов с разрешенными командами приоритет имеет самое длинное совпадение префикса. Добавьте * чтобы отклонить все команды.", "commands.commandExecutionTimeout.description": "Максимальное время в секундах для ожидания завершения выполнения команды до истечения времени ожидания (0 = без тайм-аута, 1-600с, по умолчанию: 0с)", diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index 1842156d75..2bbf661730 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -26,6 +26,7 @@ "command.settings.title": "Ayarlar", "command.documentation.title": "Dokümantasyon", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "'Her zaman yürütme işlemlerini onayla' etkinleştirildiğinde otomatik olarak yürütülebilen komutlar", "commands.deniedCommands.description": "Onay istenmeden otomatik olarak reddedilecek komut önekleri. İzin verilen komutlarla çakışma durumunda en uzun önek eşleşmesi öncelik alır. Tüm komutları reddetmek için * ekleyin.", "commands.commandExecutionTimeout.description": "Komut yürütmesinin tamamlanmasını beklemek için maksimum süre (saniye), zaman aşımından önce (0 = zaman aşımı yok, 1-600s, varsayılan: 0s)", diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index c846978793..83e3102f90 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -26,6 +26,7 @@ "command.settings.title": "Cài Đặt", "command.documentation.title": "Tài Liệu", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Các lệnh có thể được thực thi tự động khi 'Luôn phê duyệt các thao tác thực thi' được bật", "commands.deniedCommands.description": "Các tiền tố lệnh sẽ được tự động từ chối mà không yêu cầu phê duyệt. Trong trường hợp xung đột với các lệnh được phép, việc khớp tiền tố dài nhất sẽ được ưu tiên. Thêm * để từ chối tất cả các lệnh.", "commands.commandExecutionTimeout.description": "Thời gian tối đa tính bằng giây để chờ việc thực thi lệnh hoàn thành trước khi hết thời gian chờ (0 = không có thời gian chờ, 1-600s, mặc định: 0s)", diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index e2705e9e2f..f13e413614 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -26,6 +26,7 @@ "command.settings.title": "设置", "command.documentation.title": "文档", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "当启用'始终批准执行操作'时可以自动执行的命令", "commands.deniedCommands.description": "将自动拒绝而无需请求批准的命令前缀。与允许命令冲突时,最长前缀匹配优先。添加 * 拒绝所有命令。", "commands.commandExecutionTimeout.description": "等待命令执行完成的最大时间(秒),超时前(0 = 无超时,1-600秒,默认:0秒)", diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index 723d8d08ad..20c586a469 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -26,6 +26,7 @@ "command.settings.title": "設定", "command.documentation.title": "文件", "configuration.title": "Zoo Code", + "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "當啟用'始終批准執行操作'時可以自動執行的命令", "commands.deniedCommands.description": "將自動拒絕而無需請求批准的命令前綴。與允許命令衝突時,最長前綴匹配優先。新增 * 拒絕所有命令。", "commands.commandExecutionTimeout.description": "等待命令執行完成的最大時間(秒),逾時前(0 = 無逾時,1-600秒,預設:0秒)", diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index dd02c5b7e7..7c48e32696 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -101,17 +101,21 @@ export class ZooRemoteControlBridge implements vscode.Disposable { log: this.log, }) - this.disposables.push( - vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("zoo-code.remoteControl.enabled")) { - void this.syncActiveState() - } - }), - ) + if (typeof vscode.workspace.onDidChangeConfiguration === "function") { + this.disposables.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("zoo-code.remoteControl.enabled")) { + void this.syncActiveState() + } + }), + ) + } - this.provider.on(RooCodeEventName.ProviderProfileChanged, this.onProviderProfileChanged) + if (typeof this.provider.on === "function") { + this.provider.on(RooCodeEventName.ProviderProfileChanged, this.onProviderProfileChanged) + } - if (this.context.secrets?.onDidChange) { + if (typeof this.context.secrets?.onDidChange === "function") { this.disposables.push( this.context.secrets.onDidChange((event) => { if (event.key === "zoo-code-session-token") { @@ -127,7 +131,9 @@ export class ZooRemoteControlBridge implements vscode.Disposable { public dispose(): void { this.disposed = true void this.deactivate({ notifyOffline: true }) - this.provider.off(RooCodeEventName.ProviderProfileChanged, this.onProviderProfileChanged) + if (typeof this.provider.off === "function") { + this.provider.off(RooCodeEventName.ProviderProfileChanged, this.onProviderProfileChanged) + } for (const disposable of this.disposables) { disposable.dispose() } @@ -160,12 +166,20 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private isSettingEnabled(): boolean { - return vscode.workspace.getConfiguration(Package.name).get("remoteControl.enabled", false) + return vscode.workspace.getConfiguration(Package.name).get("remoteControl.enabled", false) === true } private getApiProvider(): string | undefined { - const settings: ProviderSettings = this.provider.contextProxy.getProviderSettings() - return settings.apiProvider + try { + const getSettings = this.provider.contextProxy?.getProviderSettings + if (typeof getSettings !== "function") { + return undefined + } + const settings: ProviderSettings = getSettings.call(this.provider.contextProxy) + return settings?.apiProvider + } catch { + return undefined + } } private evaluateEligible(): boolean { @@ -181,13 +195,17 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - const eligible = this.evaluateEligible() - if (eligible && !this.active) { - await this.activate() - } else if (!eligible && this.active) { - await this.deactivate({ notifyOffline: true }) - } else if (eligible && this.active) { - await this.publishPresence(true) + try { + const eligible = this.evaluateEligible() + if (eligible && !this.active) { + await this.activate() + } else if (!eligible && this.active) { + await this.deactivate({ notifyOffline: true }) + } else if (eligible && this.active) { + await this.publishPresence(true) + } + } catch (error) { + this.log(`[ZooRemoteControl] sync failed: ${error instanceof Error ? error.message : String(error)}`) } } From 9da3bfb9d096a6d8484ba7f76b258206aa6cfbb1 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:01:47 -0600 Subject: [PATCH 03/36] test: raise zoo remote-control patch coverage Cover ZooRemoteControlClient and bridge command/lifecycle paths so codecov/patch clears the 80% gate. --- .../__tests__/ZooRemoteControlBridge.spec.ts | 219 ++++++++++++++++++ .../__tests__/ZooRemoteControlClient.spec.ts | 125 ++++++++++ 2 files changed, 344 insertions(+) create mode 100644 src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index 5dc56f0e99..8c65a0a011 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -254,4 +254,223 @@ describe("ZooRemoteControlBridge", () => { expect(bridge.isActive()).toBe(false) bridge.dispose() }) + + async function activateEligibleBridge() { + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(true) + return bridge + } + + it("approves and denies asks on the current task", async () => { + const task = { approveAsk: vi.fn(), denyAsk: vi.fn() } + provider.getCurrentTask.mockReturnValue(task) + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [ + { commandId: "a1", type: "approve_ask", payload: { text: "ok" } }, + { commandId: "d1", type: "deny_ask", payload: { text: "no" } }, + ], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(task.approveAsk).toHaveBeenCalledWith({ text: "ok", images: undefined }) + expect(task.denyAsk).toHaveBeenCalledWith({ text: "no", images: undefined }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "a1", status: "acked" }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "d1", status: "acked" }) + bridge.dispose() + }) + + it("fails ask commands when there is no current task", async () => { + provider.getCurrentTask.mockReturnValue(undefined) + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [ + { commandId: "a1", type: "approve_ask" }, + { commandId: "d1", type: "deny_ask" }, + ], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "a1", + status: "failed", + errorCode: "no_current_task", + }) + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "d1", + status: "failed", + errorCode: "no_current_task", + }) + bridge.dispose() + }) + + it("rejects commands when eligibility is lost mid-flight", async () => { + client.getCommands.mockImplementation(async () => { + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.openrouter, + }) + return { + ok: true, + status: 200, + body: { commands: [{ commandId: "m1", type: "message", payload: { text: "late" } }] }, + } + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "m1", + status: "failed", + errorCode: "not_eligible", + }) + bridge.dispose() + }) + + it("cancels, resumes, and starts tasks from remote commands", async () => { + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [ + { commandId: "c1", type: "cancel_task" }, + { commandId: "r1", type: "resume_task", taskId: "task-9" }, + { commandId: "s1", type: "start_task", payload: { text: "new work" } }, + ], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(api.cancelCurrentTask).toHaveBeenCalled() + expect(api.resumeTask).toHaveBeenCalledWith("task-9") + expect(api.startNewTask).toHaveBeenCalledWith({ text: "new work", images: undefined }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "r1", status: "acked" }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "s1", status: "acked" }) + bridge.dispose() + }) + + it("fails resume_task when taskId is missing", async () => { + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { commands: [{ commandId: "r1", type: "resume_task" }] }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "r1", + status: "failed", + errorCode: "missing_task_id", + }) + bridge.dispose() + }) + + it("acks execution_failed when a command throws", async () => { + api.sendMessage.mockRejectedValueOnce(new Error("boom")) + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { commands: [{ commandId: "m1", type: "message", payload: { text: "x" } }] }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "m1", + status: "failed", + errorCode: "execution_failed", + message: "boom", + }) + bridge.dispose() + }) + + it("emits lifecycle events while active", async () => { + const bridge = await activateEligibleBridge() + + api.emit(RooCodeEventName.TaskInteractive, "task-1") + api.emit(RooCodeEventName.TaskIdle, "task-1") + api.emit(RooCodeEventName.TaskResumable, "task-1") + api.emit(RooCodeEventName.TaskCompleted, "task-1") + api.emit(RooCodeEventName.TaskAborted, "task-1") + api.emit(RooCodeEventName.TaskModeSwitched, "task-1", "code") + + await vi.advanceTimersByTimeAsync(100) + + expect(client.postEvents).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ type: "task_interactive", taskId: "task-1" }), + expect.objectContaining({ type: "task_idle", taskId: "task-1" }), + expect.objectContaining({ type: "task_resumable", taskId: "task-1" }), + expect.objectContaining({ type: "task_completed", taskId: "task-1" }), + expect.objectContaining({ type: "task_aborted", taskId: "task-1" }), + expect.objectContaining({ type: "mode_switched", taskId: "task-1", mode: "code" }), + ]), + ) + bridge.dispose() + }) + + it("re-queues event batches when postEvents fails non-404", async () => { + client.postEvents.mockResolvedValue({ ok: false, status: 500 }) + const bridge = await activateEligibleBridge() + + api.emit(RooCodeEventName.TaskIdle, "task-1") + await vi.advanceTimersByTimeAsync(100) + expect(client.postEvents).toHaveBeenCalled() + + client.postEvents.mockClear() + client.postEvents.mockResolvedValue({ ok: true, status: 200 }) + await vi.advanceTimersByTimeAsync(100) + + expect(client.postEvents).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ type: "task_idle", taskId: "task-1" })]), + ) + bridge.dispose() + }) + + it("ignores command poll responses that are not ok", async () => { + client.getCommands.mockResolvedValue({ ok: false, status: 500 }) + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + expect(client.ackCommand).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("sends message commands to the local API", async () => { + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [{ commandId: "m1", type: "message", payload: { text: "hello", images: ["img"] } }], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(api.sendMessage).toHaveBeenCalledWith("hello", ["img"]) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "m1", status: "acked" }) + bridge.dispose() + }) }) diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts new file mode 100644 index 0000000000..464c881110 --- /dev/null +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { providerIdentifiers } from "@roo-code/types" + +import { ZooRemoteControlClient } from "../ZooRemoteControlClient" + +describe("ZooRemoteControlClient", () => { + let fetchFn: ReturnType + let getSessionToken: ReturnType + let getBaseUrl: ReturnType + let log: ReturnType + let client: ZooRemoteControlClient + + beforeEach(() => { + fetchFn = vi.fn() + getSessionToken = vi.fn(() => "zoo_ext_token") + getBaseUrl = vi.fn(() => "https://www.zoocode.dev/") + log = vi.fn() + client = new ZooRemoteControlClient({ + getBaseUrl, + getSessionToken, + fetchFn: fetchFn as unknown as typeof fetch, + log, + }) + }) + + it("returns 401 when no session token is present", async () => { + getSessionToken.mockReturnValue("") + const result = await client.postPresence({ + instanceId: "i1", + apiProvider: providerIdentifiers.zooGateway, + online: true, + }) + expect(result).toEqual({ ok: false, status: 401 }) + expect(fetchFn).not.toHaveBeenCalled() + }) + + it("posts presence with bearer auth", async () => { + fetchFn.mockResolvedValue({ ok: true, status: 200 }) + const presence = { + instanceId: "i1", + apiProvider: providerIdentifiers.zooGateway, + online: true, + } + const result = await client.postPresence(presence) + expect(result).toEqual({ ok: true, status: 200 }) + expect(fetchFn).toHaveBeenCalledWith( + "https://www.zoocode.dev/api/extension/remote/presence", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer zoo_ext_token", + "Content-Type": "application/json", + }), + body: JSON.stringify(presence), + }), + ) + }) + + it("posts events", async () => { + fetchFn.mockResolvedValue({ ok: true, status: 200 }) + const events = [{ type: "task_idle" as const, taskId: "t1", timestamp: 1 }] + await client.postEvents(events) + expect(fetchFn).toHaveBeenCalledWith( + "https://www.zoocode.dev/api/extension/remote/events", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ events }), + }), + ) + }) + + it("parses command list responses", async () => { + fetchFn.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + commands: [{ commandId: "c1", type: "message", payload: { text: "hi" } }], + }), + }) + const result = await client.getCommands("inst-1") + expect(result.ok).toBe(true) + expect(result.body?.commands).toHaveLength(1) + expect(fetchFn).toHaveBeenCalledWith( + "https://www.zoocode.dev/api/extension/remote/commands?instanceId=inst-1", + expect.objectContaining({ method: "GET" }), + ) + }) + + it("acks commands", async () => { + fetchFn.mockResolvedValue({ ok: true, status: 200 }) + await client.ackCommand({ commandId: "c1", status: "acked" }) + expect(fetchFn).toHaveBeenCalledWith( + "https://www.zoocode.dev/api/extension/remote/commands/c1/ack", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ commandId: "c1", status: "acked" }), + }), + ) + }) + + it("returns non-ok status without throwing", async () => { + fetchFn.mockResolvedValue({ ok: false, status: 404 }) + const result = await client.getCommands("inst-1") + expect(result).toEqual({ ok: false, status: 404 }) + expect(log).not.toHaveBeenCalled() + }) + + it("logs non-404 failures", async () => { + fetchFn.mockResolvedValue({ ok: false, status: 500 }) + await client.postEvents([]) + expect(log).toHaveBeenCalledWith(expect.stringContaining("500")) + }) + + it("returns status 0 when fetch throws", async () => { + fetchFn.mockRejectedValue(new Error("network down")) + const result = await client.postPresence({ + instanceId: "i1", + apiProvider: providerIdentifiers.zooGateway, + online: true, + }) + expect(result).toEqual({ ok: false, status: 0 }) + expect(log).toHaveBeenCalledWith(expect.stringContaining("network down")) + }) +}) From 9fa0c7e580ac6efc974e1a4719c624247ea53752 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:04:09 -0600 Subject: [PATCH 04/36] fix: type ZooRemoteControlClient test mocks for tsc --- .../__tests__/ZooRemoteControlClient.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts index 464c881110..e62015fea8 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts @@ -6,9 +6,9 @@ import { ZooRemoteControlClient } from "../ZooRemoteControlClient" describe("ZooRemoteControlClient", () => { let fetchFn: ReturnType - let getSessionToken: ReturnType - let getBaseUrl: ReturnType - let log: ReturnType + let getSessionToken: ReturnType string>> + let getBaseUrl: ReturnType string>> + let log: ReturnType void>> let client: ZooRemoteControlClient beforeEach(() => { From 54fa33c02a422d1eb6dd2de427c2b2fcef3f5e67 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:19:20 -0600 Subject: [PATCH 05/36] fix: include instanceId when posting remote events Align the IDE event batch payload with the website /api/extension/remote/events contract. --- src/services/zoo-remote-control/ZooRemoteControlBridge.ts | 2 +- src/services/zoo-remote-control/ZooRemoteControlClient.ts | 4 ++-- .../__tests__/ZooRemoteControlBridge.spec.ts | 3 +++ .../__tests__/ZooRemoteControlClient.spec.ts | 6 +++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index 7c48e32696..7c5b27b3d6 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -364,7 +364,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } const batch = this.pendingEvents.splice(0, this.pendingEvents.length) - const result = await this.client.postEvents(batch) + const result = await this.client.postEvents(this.instanceId, batch) if (result.status === 404) { this.markUnavailable() diff --git a/src/services/zoo-remote-control/ZooRemoteControlClient.ts b/src/services/zoo-remote-control/ZooRemoteControlClient.ts index 3bb2486d3f..145b1bba66 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlClient.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlClient.ts @@ -35,8 +35,8 @@ export class ZooRemoteControlClient { return this.request("POST", "/api/extension/remote/presence", presence) } - async postEvents(events: ZooRemoteEvent[]): Promise<{ ok: boolean; status: number }> { - return this.request("POST", "/api/extension/remote/events", { events }) + async postEvents(instanceId: string, events: ZooRemoteEvent[]): Promise<{ ok: boolean; status: number }> { + return this.request("POST", "/api/extension/remote/events", { instanceId, events }) } async getCommands(instanceId: string): Promise<{ diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index 8c65a0a011..10814be081 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -220,6 +220,7 @@ describe("ZooRemoteControlBridge", () => { await vi.advanceTimersByTimeAsync(100) expect(client.postEvents).toHaveBeenCalledWith( + "instance-1", expect.arrayContaining([ expect.objectContaining({ type: "message", @@ -419,6 +420,7 @@ describe("ZooRemoteControlBridge", () => { await vi.advanceTimersByTimeAsync(100) expect(client.postEvents).toHaveBeenCalledWith( + "instance-1", expect.arrayContaining([ expect.objectContaining({ type: "task_interactive", taskId: "task-1" }), expect.objectContaining({ type: "task_idle", taskId: "task-1" }), @@ -444,6 +446,7 @@ describe("ZooRemoteControlBridge", () => { await vi.advanceTimersByTimeAsync(100) expect(client.postEvents).toHaveBeenCalledWith( + "instance-1", expect.arrayContaining([expect.objectContaining({ type: "task_idle", taskId: "task-1" })]), ) bridge.dispose() diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts index e62015fea8..ccb0245888 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts @@ -60,12 +60,12 @@ describe("ZooRemoteControlClient", () => { it("posts events", async () => { fetchFn.mockResolvedValue({ ok: true, status: 200 }) const events = [{ type: "task_idle" as const, taskId: "t1", timestamp: 1 }] - await client.postEvents(events) + await client.postEvents("inst-1", events) expect(fetchFn).toHaveBeenCalledWith( "https://www.zoocode.dev/api/extension/remote/events", expect.objectContaining({ method: "POST", - body: JSON.stringify({ events }), + body: JSON.stringify({ instanceId: "inst-1", events }), }), ) }) @@ -108,7 +108,7 @@ describe("ZooRemoteControlClient", () => { it("logs non-404 failures", async () => { fetchFn.mockResolvedValue({ ok: false, status: 500 }) - await client.postEvents([]) + await client.postEvents("inst-1", []) expect(log).toHaveBeenCalledWith(expect.stringContaining("500")) }) From d15a943a0b3730d530a0de4f4b6b508417bea035 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:57:15 -0600 Subject: [PATCH 06/36] feat: harden zoo remote control for full dashboard steer Add message shaping, partial coalesce, persistent seq, snapshot upload, switch_mode/answer_followup, and safer HTTP backoff so the Agents IDE tab can mirror and drive tasks end-to-end. --- .../ZooRemoteControlBridge.ts | 174 ++++++++++++++++-- .../ZooRemoteControlClient.ts | 9 +- .../__tests__/ZooRemoteControlBridge.spec.ts | 16 +- .../__tests__/ZooRemoteControlClient.spec.ts | 18 ++ .../__tests__/projectMessage.spec.ts | 44 +++++ .../zoo-remote-control/projectMessage.ts | 80 ++++++++ src/services/zoo-remote-control/types.ts | 15 ++ 7 files changed, 333 insertions(+), 23 deletions(-) create mode 100644 src/services/zoo-remote-control/__tests__/projectMessage.spec.ts create mode 100644 src/services/zoo-remote-control/projectMessage.ts diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index 7c5b27b3d6..3b6ecaac15 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -9,9 +9,11 @@ import { } from "@roo-code/types" import type { ClineProvider } from "../../core/webview/ClineProvider" +import type { Mode } from "../../shared/modes" import { Package } from "../../shared/package" import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../zoo-code-auth" import { isZooRemoteControlEligible } from "./eligibility" +import { projectClineMessageForRemote, projectClineMessagesForSnapshot } from "./projectMessage" import { ZooRemoteControlClient } from "./ZooRemoteControlClient" import type { ZooRemoteCommand, ZooRemoteEvent } from "./types" @@ -20,7 +22,9 @@ const COMMAND_POLL_INTERVAL_MS = 2_000 const EVENT_FLUSH_INTERVAL_MS = 100 const PARTIAL_EVENT_MIN_INTERVAL_MS = 75 const UNAVAILABLE_BACKOFF_MS = 60_000 +const NOT_ELIGIBLE_BACKOFF_MS = 30_000 const INSTANCE_ID_STATE_KEY = "zooRemoteControl.instanceId" +const EVENT_SEQ_STATE_KEY = "zooRemoteControl.eventSeq" export type ZooRemoteControlBridgeDeps = { api: Pick @@ -45,9 +49,10 @@ export class ZooRemoteControlBridge implements vscode.Disposable { private active = false private serverAvailable = true private unavailableUntil = 0 - private eventSeq = 0 + private eventSeq: number private pendingEvents: ZooRemoteEvent[] = [] private lastPartialFlushAt = 0 + private pendingPartials = new Map() private presenceTimer: ReturnType | undefined private commandTimer: ReturnType | undefined @@ -93,6 +98,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.log = deps.log ?? (() => {}) this.now = deps.now ?? (() => Date.now()) this.instanceId = this.resolveInstanceId() + this.eventSeq = this.resolveEventSeq() this.client = deps.client ?? new ZooRemoteControlClient({ @@ -165,6 +171,15 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return generated } + private resolveEventSeq(): number { + const existing = this.context.globalState.get(EVENT_SEQ_STATE_KEY) + return typeof existing === "number" && Number.isFinite(existing) && existing >= 0 ? existing : 0 + } + + private persistEventSeq(): void { + void this.context.globalState.update(EVENT_SEQ_STATE_KEY, this.eventSeq) + } + private isSettingEnabled(): boolean { return vscode.workspace.getConfiguration(Package.name).get("remoteControl.enabled", false) === true } @@ -230,6 +245,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.log("[ZooRemoteControl] enabled") await this.publishPresence(true) + await this.publishSnapshot() } private async deactivate({ notifyOffline }: { notifyOffline: boolean }): Promise { @@ -243,19 +259,16 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.clearTimers() this.bindApiListeners(false) this.pendingEvents = [] + this.pendingPartials.clear() - if (notifyOffline && this.evaluateEligibleForOfflineNotify()) { - await this.publishPresence(false) + if (notifyOffline) { + // Always try offline presence while a session token remains (even if provider changed). + await this.publishPresence(false, { force: true }) } this.log("[ZooRemoteControl] disabled") } - private evaluateEligibleForOfflineNotify(): boolean { - // Only notify offline while a Zoo session can still authenticate the call. - return Boolean(getCachedZooCodeToken()) && this.getApiProvider() === providerIdentifiers.zooGateway - } - private clearTimers(): void { if (this.presenceTimer) { clearInterval(this.presenceTimer) @@ -282,6 +295,10 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.api[method](RooCodeEventName.TaskModeSwitched, this.onModeSwitched) } + private messageKey(taskId: string, message: ClineMessage): string { + return message.messageId ?? `${taskId}:${message.ts}` + } + private async handleMessageEvent(payload: { taskId: string action?: string @@ -291,19 +308,51 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - const isPartial = payload.message.partial === true + const shaped = { + taskId: payload.taskId, + action: payload.action, + message: projectClineMessageForRemote(payload.message), + } + + const isPartial = shaped.message.partial === true if (isPartial) { + this.pendingPartials.set(this.messageKey(shaped.taskId, shaped.message), shaped) const elapsed = this.now() - this.lastPartialFlushAt if (elapsed < PARTIAL_EVENT_MIN_INTERVAL_MS) { return } - this.lastPartialFlushAt = this.now() + this.flushPendingPartials() + return + } + + this.pendingPartials.delete(this.messageKey(shaped.taskId, shaped.message)) + this.enqueueMessageEvent(shaped) + } + + private flushPendingPartials(): void { + if (this.pendingPartials.size === 0) { + return + } + this.lastPartialFlushAt = this.now() + for (const shaped of this.pendingPartials.values()) { + this.enqueueMessageEvent(shaped) } + this.pendingPartials.clear() + } + + private enqueueMessageEvent(shaped: { taskId: string; action?: string; message: ClineMessage }): void { + const action = + shaped.action === "created" || shaped.action === "updated" + ? shaped.action + : shaped.message.partial + ? "updated" + : "created" this.enqueueEvent({ type: "message", - taskId: payload.taskId, - message: payload.message, + taskId: shaped.taskId, + action, + message: shaped.message, timestamp: this.now(), }) } @@ -314,6 +363,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } this.pendingEvents.push({ ...event, seq: this.eventSeq++ }) + this.persistEventSeq() } private markUnavailable(): void { @@ -321,18 +371,23 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.unavailableUntil = this.now() + UNAVAILABLE_BACKOFF_MS } + private markNotEligibleBackoff(): void { + this.serverAvailable = false + this.unavailableUntil = this.now() + NOT_ELIGIBLE_BACKOFF_MS + } + private canCallServer(): boolean { - if (!this.active) { - return false - } if (this.serverAvailable) { return true } return this.now() >= this.unavailableUntil } - private async publishPresence(online: boolean): Promise { - if (!this.canCallServer() && online) { + private async publishPresence(online: boolean, options?: { force?: boolean }): Promise { + if (!options?.force && !this.canCallServer() && online) { + return + } + if (!getCachedZooCodeToken()) { return } @@ -348,6 +403,53 @@ export class ZooRemoteControlBridge implements vscode.Disposable { extensionVersion: Package.version, }) + if (result.status === 401 || result.status === 402 || result.status === 403) { + this.markNotEligibleBackoff() + if (this.active) { + await this.deactivate({ notifyOffline: false }) + } + return + } + + if (result.status === 404) { + this.markUnavailable() + return + } + + if (result.ok) { + this.serverAvailable = true + } + } + + private async publishSnapshot(): Promise { + if (!this.active || !this.canCallServer()) { + return + } + + const task = this.provider.getCurrentTask() + if (!task?.taskId || !Array.isArray(task.clineMessages)) { + return + } + + let mode: string | undefined + try { + mode = task.taskMode + } catch { + mode = undefined + } + + const result = await this.client.postSnapshot({ + instanceId: this.instanceId, + taskId: task.taskId, + messages: projectClineMessagesForSnapshot(task.clineMessages), + mode, + }) + + if (result.status === 401 || result.status === 402 || result.status === 403) { + this.markNotEligibleBackoff() + return + } + if (result.status === 404) { this.markUnavailable() return @@ -359,13 +461,24 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private async flushEvents(): Promise { - if (!this.active || this.pendingEvents.length === 0 || !this.canCallServer()) { + if (!this.active || !this.canCallServer()) { + return + } + + this.flushPendingPartials() + if (this.pendingEvents.length === 0) { return } const batch = this.pendingEvents.splice(0, this.pendingEvents.length) const result = await this.client.postEvents(this.instanceId, batch) + if (result.status === 401 || result.status === 402 || result.status === 403) { + this.markNotEligibleBackoff() + this.pendingEvents = [...batch.slice(-50), ...this.pendingEvents].slice(0, 200) + return + } + if (result.status === 404) { this.markUnavailable() // Drop batch while the public endpoint is not available yet. @@ -373,7 +486,6 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } if (!result.ok) { - // Re-queue failed non-404 batches at the front (bounded). this.pendingEvents = [...batch.slice(-50), ...this.pendingEvents].slice(0, 200) return } @@ -393,6 +505,12 @@ export class ZooRemoteControlBridge implements vscode.Disposable { const result = await this.client.getCommands(this.instanceId) + if (result.status === 401 || result.status === 402 || result.status === 403) { + this.markNotEligibleBackoff() + await this.deactivate({ notifyOffline: false }) + return + } + if (result.status === 404) { this.markUnavailable() return @@ -422,7 +540,11 @@ export class ZooRemoteControlBridge implements vscode.Disposable { try { switch (command.type) { case "message": + case "answer_followup": await this.api.sendMessage(command.payload?.text, command.payload?.images) + if (command.payload?.mode) { + await this.provider.handleModeSwitch(command.payload.mode as Mode) + } break case "approve_ask": { const task = this.provider.getCurrentTask() @@ -470,6 +592,19 @@ export class ZooRemoteControlBridge implements vscode.Disposable { images: command.payload?.images, }) break + case "switch_mode": { + const mode = command.payload?.mode + if (!mode) { + await this.client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "missing_mode", + }) + return + } + await this.provider.handleModeSwitch(mode as Mode) + break + } default: { const _exhaustive: never = command.type await this.client.ackCommand({ @@ -482,6 +617,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } } + // Ack 404 (command_not_found) must not mark the bridge unavailable. await this.client.ackCommand({ commandId: command.commandId, status: "acked" }) } catch (error) { await this.client.ackCommand({ diff --git a/src/services/zoo-remote-control/ZooRemoteControlClient.ts b/src/services/zoo-remote-control/ZooRemoteControlClient.ts index 145b1bba66..87bc28346e 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlClient.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlClient.ts @@ -1,10 +1,10 @@ import { - zooRemoteCommandAckSchema, zooRemoteCommandsResponseSchema, type ZooRemoteCommandAck, type ZooRemoteCommandsResponse, type ZooRemoteEvent, type ZooRemotePresence, + type ZooRemoteSnapshot, } from "./types" export type ZooRemoteControlClientOptions = { @@ -16,7 +16,8 @@ export type ZooRemoteControlClientOptions = { /** * Thin HTTP client for /api/extension/remote/* using the Zoo session token. - * Callers treat 404 as "server not ready" and back off. + * Callers treat route-missing 404 as "server not ready" and back off. + * Ack 404 (command_not_found) must not use the same backoff path. */ export class ZooRemoteControlClient { private readonly getBaseUrl: () => string @@ -39,6 +40,10 @@ export class ZooRemoteControlClient { return this.request("POST", "/api/extension/remote/events", { instanceId, events }) } + async postSnapshot(snapshot: ZooRemoteSnapshot): Promise<{ ok: boolean; status: number }> { + return this.request("POST", "/api/extension/remote/snapshot", snapshot) + } + async getCommands(instanceId: string): Promise<{ ok: boolean status: number diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index 10814be081..91ef6b19a1 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -38,10 +38,12 @@ describe("ZooRemoteControlBridge", () => { let provider: EventEmitter & { contextProxy: { getProviderSettings: ReturnType } getCurrentTask: ReturnType + handleModeSwitch: ReturnType } let client: { postPresence: ReturnType postEvents: ReturnType + postSnapshot: ReturnType getCommands: ReturnType ackCommand: ReturnType } @@ -74,18 +76,24 @@ describe("ZooRemoteControlBridge", () => { getProviderSettings: vi.fn(() => ({ apiProvider: providerIdentifiers.openrouter })), }, getCurrentTask: vi.fn(() => undefined), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), }) client = { postPresence: vi.fn().mockResolvedValue({ ok: true, status: 200 }), postEvents: vi.fn().mockResolvedValue({ ok: true, status: 200 }), + postSnapshot: vi.fn().mockResolvedValue({ ok: true, status: 200 }), getCommands: vi.fn().mockResolvedValue({ ok: true, status: 200, body: { commands: [] } }), ackCommand: vi.fn().mockResolvedValue({ ok: true, status: 200 }), } context = { globalState: { - get: vi.fn(() => "instance-1"), + get: vi.fn((key: string) => { + if (key === "zooRemoteControl.instanceId") return "instance-1" + if (key === "zooRemoteControl.eventSeq") return 0 + return undefined + }), update: vi.fn().mockResolvedValue(undefined), }, secrets: { @@ -344,7 +352,8 @@ describe("ZooRemoteControlBridge", () => { bridge.dispose() }) - it("cancels, resumes, and starts tasks from remote commands", async () => { + it("cancels, resumes, starts, and switches mode from remote commands", async () => { + provider.handleModeSwitch = vi.fn().mockResolvedValue(undefined) client.getCommands.mockResolvedValueOnce({ ok: true, status: 200, @@ -353,6 +362,7 @@ describe("ZooRemoteControlBridge", () => { { commandId: "c1", type: "cancel_task" }, { commandId: "r1", type: "resume_task", taskId: "task-9" }, { commandId: "s1", type: "start_task", payload: { text: "new work" } }, + { commandId: "m1", type: "switch_mode", payload: { mode: "code" } }, ], }, }) @@ -363,9 +373,11 @@ describe("ZooRemoteControlBridge", () => { expect(api.cancelCurrentTask).toHaveBeenCalled() expect(api.resumeTask).toHaveBeenCalledWith("task-9") expect(api.startNewTask).toHaveBeenCalledWith({ text: "new work", images: undefined }) + expect(provider.handleModeSwitch).toHaveBeenCalledWith("code") expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "r1", status: "acked" }) expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "s1", status: "acked" }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "m1", status: "acked" }) bridge.dispose() }) diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts index ccb0245888..d20b219d7e 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts @@ -70,6 +70,24 @@ describe("ZooRemoteControlClient", () => { ) }) + it("posts snapshots", async () => { + fetchFn.mockResolvedValue({ ok: true, status: 200 }) + const snapshot = { + instanceId: "inst-1", + taskId: "t1", + messages: [{ ts: 1, type: "say" as const, say: "text" as const, text: "hi" }], + mode: "code", + } + await client.postSnapshot(snapshot) + expect(fetchFn).toHaveBeenCalledWith( + "https://www.zoocode.dev/api/extension/remote/snapshot", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(snapshot), + }), + ) + }) + it("parses command list responses", async () => { fetchFn.mockResolvedValue({ ok: true, diff --git a/src/services/zoo-remote-control/__tests__/projectMessage.spec.ts b/src/services/zoo-remote-control/__tests__/projectMessage.spec.ts new file mode 100644 index 0000000000..356bef57b2 --- /dev/null +++ b/src/services/zoo-remote-control/__tests__/projectMessage.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest" + +import { projectClineMessageForRemote, projectClineMessagesForSnapshot } from "../projectMessage" + +describe("projectClineMessageForRemote", () => { + it("truncates oversized tool diff payloads while keeping path metadata", () => { + const huge = "x".repeat(60_000) + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "appliedDiff", + path: "src/a.ts", + diff: huge, + diffStats: { added: 1, removed: 0 }, + }), + }) + + expect(shaped.text).toBeTruthy() + const parsed = JSON.parse(shaped.text!) as { + path: string + diff: string + diffStats: { added: number } + } + expect(parsed.path).toBe("src/a.ts") + expect(parsed.diffStats.added).toBe(1) + expect(parsed.diff.length).toBeLessThan(huge.length) + expect(parsed.diff).toContain("[truncated") + }) + + it("limits snapshot windows", () => { + const messages = Array.from({ length: 120 }, (_, i) => ({ + ts: i, + type: "say" as const, + say: "text" as const, + text: `m${i}`, + })) + const window = projectClineMessagesForSnapshot(messages) + expect(window).toHaveLength(80) + expect(window[0]?.text).toBe("m40") + expect(window.at(-1)?.text).toBe("m119") + }) +}) diff --git a/src/services/zoo-remote-control/projectMessage.ts b/src/services/zoo-remote-control/projectMessage.ts new file mode 100644 index 0000000000..356f0a53d4 --- /dev/null +++ b/src/services/zoo-remote-control/projectMessage.ts @@ -0,0 +1,80 @@ +import type { ClineMessage } from "@roo-code/types" + +/** Cap remote tool payloads so dashboard mirror stays usable without huge transfers. */ +export const REMOTE_MESSAGE_TEXT_MAX_CHARS = 48_000 +export const REMOTE_MESSAGE_IMAGE_MAX_COUNT = 2 +export const REMOTE_MESSAGE_IMAGE_MAX_CHARS = 8_000 +export const REMOTE_SNAPSHOT_MAX_MESSAGES = 80 + +function truncateString(value: string, max: number): string { + if (value.length <= max) { + return value + } + return `${value.slice(0, max)}\n…[truncated ${value.length - max} chars]` +} + +function shapeToolText(text: string): string { + try { + const parsed: unknown = JSON.parse(text) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return truncateString(text, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } + + const record = { ...(parsed as Record) } + for (const key of ["content", "diff", "originalContent", "code", "result"] as const) { + const value = record[key] + if (typeof value === "string") { + record[key] = truncateString(value, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } + } + + if (Array.isArray(record.batchDiffs)) { + record.batchDiffs = record.batchDiffs.slice(0, 20).map((entry) => { + if (!entry || typeof entry !== "object") { + return entry + } + const item = { ...(entry as Record) } + if (typeof item.diff === "string") { + item.diff = truncateString(item.diff, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } + if (typeof item.content === "string") { + item.content = truncateString(item.content, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } + return item + }) + } + + return JSON.stringify(record) + } catch { + return truncateString(text, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } +} + +/** Shape a ClineMessage for remote publish (size-safe, still useful for asks/diffs). */ +export function projectClineMessageForRemote(message: ClineMessage): ClineMessage { + const next: ClineMessage = { ...message } + + if (typeof next.text === "string") { + const isToolish = + next.ask === "tool" || next.say === "tool" || next.ask === "command" || next.ask === "use_mcp_server" + + next.text = isToolish ? shapeToolText(next.text) : truncateString(next.text, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } + + if (typeof next.reasoning === "string") { + next.reasoning = truncateString(next.reasoning, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } + + if (Array.isArray(next.images) && next.images.length > 0) { + next.images = next.images + .slice(0, REMOTE_MESSAGE_IMAGE_MAX_COUNT) + .map((image) => truncateString(image, REMOTE_MESSAGE_IMAGE_MAX_CHARS)) + } + + return next +} + +export function projectClineMessagesForSnapshot(messages: ClineMessage[]): ClineMessage[] { + const window = messages.slice(-REMOTE_SNAPSHOT_MAX_MESSAGES) + return window.map(projectClineMessageForRemote) +} diff --git a/src/services/zoo-remote-control/types.ts b/src/services/zoo-remote-control/types.ts index 15ec60fa20..32b8f58567 100644 --- a/src/services/zoo-remote-control/types.ts +++ b/src/services/zoo-remote-control/types.ts @@ -14,6 +14,8 @@ export const zooRemoteCommandTypeSchema = z.enum([ "cancel_task", "resume_task", "start_task", + "switch_mode", + "answer_followup", ]) export type ZooRemoteCommandType = z.infer @@ -26,6 +28,7 @@ export const zooRemoteCommandSchema = z.object({ .object({ text: z.string().optional(), images: z.array(z.string()).optional(), + mode: z.string().optional(), }) .optional(), timestamp: z.number().optional(), @@ -51,10 +54,13 @@ export const zooRemoteEventTypeSchema = z.enum([ export type ZooRemoteEventType = z.infer +export const zooRemoteMessageActionSchema = z.enum(["created", "updated"]) + export const zooRemoteEventSchema = z.object({ type: zooRemoteEventTypeSchema, taskId: z.string().min(1), message: clineMessageSchema.optional(), + action: zooRemoteMessageActionSchema.optional(), mode: z.string().optional(), seq: z.number().int().nonnegative().optional(), timestamp: z.number(), @@ -82,3 +88,12 @@ export const zooRemoteCommandAckSchema = z.object({ }) export type ZooRemoteCommandAck = z.infer + +export const zooRemoteSnapshotSchema = z.object({ + instanceId: z.string().min(1), + taskId: z.string().min(1), + messages: z.array(clineMessageSchema).max(500), + mode: z.string().optional(), +}) + +export type ZooRemoteSnapshot = z.infer From cb5b3a1760333d29a62029447ce9f4ac1803ceb4 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:31:46 -0600 Subject: [PATCH 07/36] test: raise zoo remote control patch coverage for codecov --- .../__tests__/ZooRemoteControlBridge.spec.ts | 297 ++++++++++++++++++ .../__tests__/projectMessage.spec.ts | 133 +++++++- 2 files changed, 425 insertions(+), 5 deletions(-) diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index 91ef6b19a1..845b7669fc 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -488,4 +488,301 @@ describe("ZooRemoteControlBridge", () => { expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "m1", status: "acked" }) bridge.dispose() }) + + it("answers follow-ups and can switch mode from the payload", async () => { + provider.handleModeSwitch = vi.fn().mockResolvedValue(undefined) + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [ + { + commandId: "f1", + type: "answer_followup", + payload: { text: "option a", mode: "architect" }, + }, + ], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(api.sendMessage).toHaveBeenCalledWith("option a", undefined) + expect(provider.handleModeSwitch).toHaveBeenCalledWith("architect") + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "f1", status: "acked" }) + bridge.dispose() + }) + + it("fails switch_mode when mode is missing", async () => { + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { commands: [{ commandId: "m1", type: "switch_mode", payload: {} }] }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "m1", + status: "failed", + errorCode: "missing_mode", + }) + bridge.dispose() + }) + + it("deactivates on auth/billing presence failures", async () => { + const bridge = await activateEligibleBridge() + client.postPresence.mockResolvedValueOnce({ ok: false, status: 401 }) + await vi.advanceTimersByTimeAsync(15_000) + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) + + it("marks the bridge unavailable on command 404 and recovers after backoff", async () => { + const bridge = await activateEligibleBridge() + client.getCommands.mockResolvedValueOnce({ ok: false, status: 404 }) + await vi.advanceTimersByTimeAsync(2_000) + + client.getCommands.mockClear() + client.postPresence.mockClear() + await vi.advanceTimersByTimeAsync(15_000) + expect(client.postPresence).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(50_000) + client.postPresence.mockResolvedValue({ ok: true, status: 200 }) + client.getCommands.mockResolvedValue({ ok: true, status: 200, body: { commands: [] } }) + await vi.advanceTimersByTimeAsync(15_000) + expect(client.postPresence).toHaveBeenCalled() + bridge.dispose() + }) + + it("deactivates when command polling returns 403", async () => { + const bridge = await activateEligibleBridge() + client.getCommands.mockResolvedValueOnce({ ok: false, status: 403 }) + await vi.advanceTimersByTimeAsync(2_000) + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) + + it("publishes a snapshot when the current task has messages", async () => { + provider.getCurrentTask.mockReturnValue({ + taskId: "task-snap", + taskMode: "code", + clineMessages: [ + { ts: 1, type: "say", say: "text", text: "hello" }, + { ts: 2, type: "ask", ask: "tool", text: JSON.stringify({ path: "a.ts", diff: "x" }) }, + ], + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(0) + + expect(client.postSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + instanceId: "instance-1", + taskId: "task-snap", + mode: "code", + messages: expect.arrayContaining([expect.objectContaining({ text: "hello" })]), + }), + ) + bridge.dispose() + }) + + it("coalesces partial message events and flushes them", async () => { + const bridge = await activateEligibleBridge() + client.postEvents.mockClear() + + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + action: "updated", + message: { ts: 9, type: "say", say: "text", text: "par", partial: true, messageId: "m9" }, + }) + await vi.advanceTimersByTimeAsync(100) + + expect(client.postEvents).toHaveBeenCalledWith( + "instance-1", + expect.arrayContaining([ + expect.objectContaining({ + type: "message", + action: "updated", + message: expect.objectContaining({ text: "par", partial: true }), + }), + ]), + ) + bridge.dispose() + }) + + it("throttles rapid partial flushes", async () => { + const bridge = await activateEligibleBridge() + client.postEvents.mockClear() + + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + message: { ts: 1, type: "say", say: "text", text: "a", partial: true, messageId: "p1" }, + }) + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + message: { ts: 1, type: "say", say: "text", text: "ab", partial: true, messageId: "p1" }, + }) + await vi.advanceTimersByTimeAsync(50) + const earlyCalls = client.postEvents.mock.calls.length + + await vi.advanceTimersByTimeAsync(100) + expect(client.postEvents.mock.calls.length).toBeGreaterThanOrEqual(earlyCalls) + bridge.dispose() + }) + + it("drops event batches on postEvents 404", async () => { + client.postEvents.mockResolvedValue({ ok: false, status: 404 }) + const bridge = await activateEligibleBridge() + api.emit(RooCodeEventName.TaskIdle, "task-1") + await vi.advanceTimersByTimeAsync(100) + + client.postEvents.mockClear() + client.postEvents.mockResolvedValue({ ok: true, status: 200 }) + await vi.advanceTimersByTimeAsync(100) + // 404 path drops the batch rather than re-queueing it. + expect(client.postEvents).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("re-queues event batches when postEvents returns 401", async () => { + client.postEvents.mockResolvedValue({ ok: false, status: 401 }) + const bridge = await activateEligibleBridge() + api.emit(RooCodeEventName.TaskIdle, "task-1") + await vi.advanceTimersByTimeAsync(100) + expect(client.postEvents).toHaveBeenCalled() + bridge.dispose() + }) + + it("syncs when already active by republishing presence", async () => { + const bridge = await activateEligibleBridge() + client.postPresence.mockClear() + provider.emit(RooCodeEventName.ProviderProfileChanged, { + name: "Zoo Gateway", + provider: providerIdentifiers.zooGateway, + }) + await vi.advanceTimersByTimeAsync(0) + expect(client.postPresence).toHaveBeenCalled() + expect(bridge.isActive()).toBe(true) + bridge.dispose() + }) + + it("reacts to configuration and secret changes", async () => { + let configListener: ((event: { affectsConfiguration: (key: string) => boolean }) => void) | undefined + let secretListener: ((event: { key: string }) => void) | undefined + vi.mocked(vscode.workspace.onDidChangeConfiguration).mockImplementation((listener) => { + configListener = listener as typeof configListener + return { dispose: vi.fn() } + }) + context.secrets.onDidChange.mockImplementation((listener: (event: { key: string }) => void) => { + secretListener = listener + return { dispose: vi.fn() } + }) + + getConfigValue = () => false + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(false) + + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + configListener?.({ affectsConfiguration: (key) => key === "zoo-code.remoteControl.enabled" }) + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(true) + + vi.mocked(getCachedZooCodeToken).mockReturnValue("") + secretListener?.({ key: "zoo-code-session-token" }) + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) + + it("generates and persists an instance id when none is stored", async () => { + context.globalState.get.mockImplementation((key: string) => { + if (key === "zooRemoteControl.eventSeq") return -1 + return undefined + }) + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.getInstanceId()).toBe("test-session-id") + expect(context.globalState.update).toHaveBeenCalledWith("zooRemoteControl.instanceId", "test-session-id") + bridge.dispose() + }) + + it("tolerates provider settings lookup failures", async () => { + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockImplementation(() => { + throw new Error("settings boom") + }) + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) + + it("skips snapshot publish when the current task has no messages", async () => { + provider.getCurrentTask.mockReturnValue({ taskId: "task-empty", clineMessages: undefined }) + const bridge = await activateEligibleBridge() + expect(client.postSnapshot).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("publishes snapshot 404 as unavailable without crashing", async () => { + provider.getCurrentTask.mockReturnValue({ + taskId: "task-snap", + clineMessages: [{ ts: 1, type: "say", say: "text", text: "hi" }], + }) + client.postSnapshot.mockResolvedValue({ ok: false, status: 404 }) + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(0) + expect(client.postSnapshot).toHaveBeenCalled() + bridge.dispose() + }) + + it("deactivates during command poll when eligibility is lost before fetch", async () => { + const bridge = await activateEligibleBridge() + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.openrouter, + }) + client.getCommands.mockClear() + await vi.advanceTimersByTimeAsync(2_000) + expect(bridge.isActive()).toBe(false) + expect(client.getCommands).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("acks unknown command types as failed", async () => { + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [{ commandId: "u1", type: "not_a_real_command" }], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith( + expect.objectContaining({ + commandId: "u1", + status: "failed", + errorCode: "unknown_command", + }), + ) + bridge.dispose() + }) }) diff --git a/src/services/zoo-remote-control/__tests__/projectMessage.spec.ts b/src/services/zoo-remote-control/__tests__/projectMessage.spec.ts index 356bef57b2..392c367a6c 100644 --- a/src/services/zoo-remote-control/__tests__/projectMessage.spec.ts +++ b/src/services/zoo-remote-control/__tests__/projectMessage.spec.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest" -import { projectClineMessageForRemote, projectClineMessagesForSnapshot } from "../projectMessage" +import { + projectClineMessageForRemote, + projectClineMessagesForSnapshot, + REMOTE_MESSAGE_IMAGE_MAX_CHARS, + REMOTE_MESSAGE_IMAGE_MAX_COUNT, + REMOTE_MESSAGE_TEXT_MAX_CHARS, + REMOTE_SNAPSHOT_MAX_MESSAGES, +} from "../projectMessage" describe("projectClineMessageForRemote", () => { it("truncates oversized tool diff payloads while keeping path metadata", () => { @@ -29,16 +36,132 @@ describe("projectClineMessageForRemote", () => { expect(parsed.diff).toContain("[truncated") }) + it("shapes say:tool, command, and use_mcp_server asks", () => { + const huge = "y".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 100) + for (const message of [ + { + ts: 1, + type: "say" as const, + say: "tool" as const, + text: JSON.stringify({ content: huge, code: huge }), + }, + { + ts: 2, + type: "ask" as const, + ask: "command" as const, + text: JSON.stringify({ result: huge }), + }, + { + ts: 3, + type: "ask" as const, + ask: "use_mcp_server" as const, + text: JSON.stringify({ originalContent: huge }), + }, + ]) { + const shaped = projectClineMessageForRemote(message) + const parsed = JSON.parse(shaped.text!) as Record + for (const value of Object.values(parsed)) { + if (typeof value === "string" && value.length > 100) { + expect(value.length).toBeLessThanOrEqual(REMOTE_MESSAGE_TEXT_MAX_CHARS + 40) + expect(value).toContain("[truncated") + } + } + } + }) + + it("truncates non-JSON tool text and invalid JSON arrays/objects", () => { + const huge = "z".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 50) + expect(projectClineMessageForRemote({ ts: 1, type: "ask", ask: "tool", text: huge }).text).toContain( + "[truncated", + ) + expect( + projectClineMessageForRemote({ + ts: 2, + type: "ask", + ask: "tool", + text: JSON.stringify(["not", "an", "object"]), + }).text, + ).toBe(JSON.stringify(["not", "an", "object"])) + expect( + projectClineMessageForRemote({ + ts: 3, + type: "ask", + ask: "tool", + text: `{${"n".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 20)}`, + }).text, + ).toContain("[truncated") + }) + + it("shapes batchDiffs entries and leaves non-object batch entries untouched", () => { + const huge = "d".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 10) + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify({ + batchDiffs: [ + { path: "a.ts", diff: huge, content: huge }, + "skip-me", + null, + { path: "b.ts", note: "ok" }, + ...Array.from({ length: 25 }, (_, i) => ({ path: `x${i}.ts`, diff: "small" })), + ], + }), + }) + const parsed = JSON.parse(shaped.text!) as { + batchDiffs: Array + } + expect(parsed.batchDiffs).toHaveLength(20) + expect(parsed.batchDiffs[0]).toMatchObject({ path: "a.ts" }) + expect((parsed.batchDiffs[0] as { diff: string }).diff).toContain("[truncated") + expect((parsed.batchDiffs[0] as { content: string }).content).toContain("[truncated") + expect(parsed.batchDiffs[1]).toBe("skip-me") + expect(parsed.batchDiffs[2]).toBeNull() + }) + + it("truncates plain text, reasoning, and image payloads", () => { + const hugeText = "t".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 20) + const hugeImage = "i".repeat(REMOTE_MESSAGE_IMAGE_MAX_CHARS + 20) + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "say", + say: "text", + text: hugeText, + reasoning: hugeText, + images: [hugeImage, hugeImage, hugeImage], + }) + expect(shaped.text).toContain("[truncated") + expect(shaped.reasoning).toContain("[truncated") + expect(shaped.images).toHaveLength(REMOTE_MESSAGE_IMAGE_MAX_COUNT) + expect(shaped.images?.[0]).toContain("[truncated") + }) + + it("leaves short non-tool messages unchanged", () => { + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "say", + say: "text", + text: "hello", + reasoning: "think", + images: ["img"], + }) + expect(shaped).toMatchObject({ + text: "hello", + reasoning: "think", + images: ["img"], + }) + }) + it("limits snapshot windows", () => { - const messages = Array.from({ length: 120 }, (_, i) => ({ + const messages = Array.from({ length: REMOTE_SNAPSHOT_MAX_MESSAGES + 40 }, (_, i) => ({ ts: i, type: "say" as const, say: "text" as const, text: `m${i}`, })) const window = projectClineMessagesForSnapshot(messages) - expect(window).toHaveLength(80) - expect(window[0]?.text).toBe("m40") - expect(window.at(-1)?.text).toBe("m119") + expect(window).toHaveLength(REMOTE_SNAPSHOT_MAX_MESSAGES) + expect(window[0]?.text).toBe(`m${40}`) + expect(window.at(-1)?.text).toBe(`m${REMOTE_SNAPSHOT_MAX_MESSAGES + 39}`) }) }) From 11439c3037efeea2620b4d43bf94da9e8ea4d128 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:06:52 -0600 Subject: [PATCH 08/36] feat: offer remote steer opt-in from the task header banner --- packages/types/src/vscode-extension-host.ts | 2 + src/core/webview/ClineProvider.ts | 3 + src/core/webview/webviewMessageHandler.ts | 5 +- webview-ui/src/components/chat/TaskHeader.tsx | 78 ++++++++++++++++++- .../chat/__tests__/TaskHeader.spec.tsx | 75 ++++++++++++++++++ webview-ui/src/i18n/locales/en/chat.json | 5 +- 6 files changed, 163 insertions(+), 5 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c0e8509105..f6f8162b9b 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -420,6 +420,8 @@ export type ExtensionState = Pick< zooCodeUserEmail?: string zooCodeUserImage?: string zooCodeBaseUrl?: string + /** VS Code setting zoo-code.remoteControl.enabled — dashboard remote steer opt-in. */ + remoteControlEnabled?: boolean deviceName?: string debug?: boolean diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 444ec03ea6..6900bdae5f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2906,6 +2906,9 @@ export class ClineProvider } })(), ...zooCodeState, + remoteControlEnabled: vscode.workspace + .getConfiguration(Package.name) + .get("remoteControl.enabled", false), platform: process.platform, arch: process.arch, debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 193540455b..f9db303472 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -93,7 +93,7 @@ import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { getCommand } from "../../utils/commands" import { getLMStudioModels } from "../../api/providers/fetchers/lmstudio" -const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) +const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv", "zoo-code.remoteControl.enabled"]) // Serializes handling of "telemetrySetting" messages. Each invocation reads the previous // setting, awaits a persistence write, then applies the new live telemetry state -- with no @@ -1953,6 +1953,9 @@ export const webviewMessageHandler = async ( if (setting !== undefined && value !== undefined) { if (ALLOWED_VSCODE_SETTINGS.has(setting)) { await vscode.workspace.getConfiguration().update(setting, value, true) + if (setting === "zoo-code.remoteControl.enabled") { + await provider.postStateToWebview() + } } else { vscode.window.showErrorMessage(`Cannot update restricted VSCode setting: ${setting}`) } diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 0941a22e2b..9f473f31cf 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,4 +1,4 @@ -import { memo, useRef, useState, useMemo } from "react" +import { memo, useEffect, useRef, useState, useMemo } from "react" import { useTranslation } from "react-i18next" import { ChevronUp, @@ -11,9 +11,10 @@ import { } from "lucide-react" import prettyBytes from "pretty-bytes" -import type { ClineMessage } from "@roo-code/types" +import { providerIdentifiers, type ClineMessage } from "@roo-code/types" import { getModelMaxOutputTokens } from "@roo/api" +import { findLastIndex } from "@roo/array" import { formatLargeNumber } from "@src/utils/format" import { cn } from "@src/lib/utils" @@ -21,6 +22,7 @@ import { StandardTooltip, Button, Table, TableBody, TableRow, TableCell, Circula import { useExtensionState } from "@src/context/ExtensionStateContext" import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" import { vscode } from "@src/utils/vscode" +import DismissibleUpsell from "@src/components/common/DismissibleUpsell" import Thumbnails from "../common/Thumbnails" import { TaskActions } from "./TaskActions" @@ -63,14 +65,60 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem } = useExtensionState() + const { + apiConfiguration, + currentTaskItem, + clineMessages, + zooCodeIsAuthenticated, + zooCodeBaseUrl, + remoteControlEnabled, + } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) + const [showRemoteSteerOffer, setShowRemoteSteerOffer] = useState(false) const textContainerRef = useRef(null) const textRef = useRef(null) const contextWindow = model?.contextWindow || 1 + const isZooGateway = apiConfiguration?.apiProvider === providerIdentifiers.zooGateway + const canOfferRemoteSteer = Boolean(zooCodeIsAuthenticated && isZooGateway && currentTaskItem) + + const isTaskComplete = + clineMessages && clineMessages.length > 0 + ? (() => { + const lastRelevantIndex = findLastIndex( + clineMessages, + (msg) => !(msg.ask === "resume_task" || msg.ask === "resume_completed_task"), + ) + const last = lastRelevantIndex >= 0 ? clineMessages[lastRelevantIndex] : undefined + return last?.ask === "completion_result" || last?.say === "completion_result" + })() + : false + + useEffect(() => { + if (!canOfferRemoteSteer || remoteControlEnabled || isTaskComplete) { + setShowRemoteSteerOffer(false) + return + } + // Match Roo's long-running-task cadence: offer after the task has been going a bit. + const timer = setTimeout(() => setShowRemoteSteerOffer(true), 120_000) + return () => clearTimeout(timer) + }, [canOfferRemoteSteer, remoteControlEnabled, isTaskComplete, currentTaskItem?.id]) + + const enableRemoteSteer = () => { + vscode.postMessage({ + type: "updateVSCodeSetting", + setting: "zoo-code.remoteControl.enabled", + value: true, + }) + } + + const openRemoteDashboard = () => { + const base = (zooCodeBaseUrl || "https://www.zoocode.dev").replace(/\/$/, "") + vscode.postMessage({ type: "openExternal", url: `${base}/dashboard/agents/ide` }) + } + // Calculate maxTokens (reserved for output) once for reuse in percentage and tooltip const maxTokens = useMemo( () => @@ -142,6 +190,30 @@ const TaskHeader = ({ )} + {canOfferRemoteSteer && remoteControlEnabled && !isTaskComplete && ( +
e.stopPropagation()}> + {t("chat:task.steerRemotelyActive")} + +
+ )} + {showRemoteSteerOffer && !isTaskComplete && ( +
e.stopPropagation()}> + + {t("chat:task.steerRemotely")} + +
+ )}
({ VSCodeBadge: ({ children }: { children: React.ReactNode }) =>
{children}
, })) +vi.mock("@src/components/common/DismissibleUpsell", () => ({ + default: ({ + children, + onClick, + upsellId, + }: { + children: React.ReactNode + onClick?: () => void + upsellId: string + }) => ( + + ), +})) + // Create a variable to hold the mock state const mockExtensionState: { apiConfiguration: ProviderSettings currentTaskItem: { id: string } | null clineMessages: any[] taskHistory: any[] + zooCodeIsAuthenticated?: boolean + zooCodeBaseUrl?: string + remoteControlEnabled?: boolean } = { apiConfiguration: { apiProvider: providerIdentifiers.anthropic, @@ -50,6 +69,8 @@ const mockExtensionState: { currentTaskItem: { id: "test-task-id" }, clineMessages: [], taskHistory: [], + zooCodeIsAuthenticated: false, + remoteControlEnabled: false, } // Mock the ExtensionStateContext @@ -330,4 +351,58 @@ describe("TaskHeader", () => { expect(screen.getByText("25%")).toBeInTheDocument() }) }) + + describe("remote steer banner", () => { + beforeEach(() => { + vi.useFakeTimers() + mockPostMessage.mockClear() + mockExtensionState.apiConfiguration = { + apiProvider: providerIdentifiers.zooGateway, + } as ProviderSettings + mockExtensionState.zooCodeIsAuthenticated = true + mockExtensionState.remoteControlEnabled = false + mockExtensionState.clineMessages = [{ type: "say", say: "text", ts: 1, text: "working" }] + }) + + afterEach(() => { + vi.useRealTimers() + mockExtensionState.apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-api-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings + mockExtensionState.zooCodeIsAuthenticated = false + mockExtensionState.remoteControlEnabled = false + mockExtensionState.clineMessages = [] + }) + + it("offers remote steer after two minutes and enables it on click", async () => { + renderTaskHeader() + expect(screen.queryByTestId("upsell-zooRemoteSteer")).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(120_000) + const offer = screen.getByTestId("upsell-zooRemoteSteer") + expect(offer).toHaveTextContent("chat:task.steerRemotely") + + fireEvent.click(offer) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "updateVSCodeSetting", + setting: "zoo-code.remoteControl.enabled", + value: true, + }) + }) + + it("shows the active remote steer strip with a dashboard link", () => { + mockExtensionState.remoteControlEnabled = true + mockExtensionState.zooCodeBaseUrl = "https://www.zoocode.dev" + renderTaskHeader() + + expect(screen.getByText("chat:task.steerRemotelyActive")).toBeInTheDocument() + fireEvent.click(screen.getByText("chat:task.openRemoteDashboard")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openExternal", + url: "https://www.zoocode.dev/dashboard/agents/ide", + }) + }) + }) }) diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 460e94413e..67b29ff984 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -19,7 +19,10 @@ "openUiHistory": "Open UI History", "backToParentTask": "Parent task", "waitingOnSubtask": "Waiting on subtask", - "goToSubtask": "Go to subtask" + "goToSubtask": "Go to subtask", + "steerRemotely": "Need to step away? Steer this task remotely from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Unpin", "pin": "Pin", From 07d8ccd2f4bbf6de84a153fc89bdd5ea0b847651 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:11:36 -0600 Subject: [PATCH 09/36] refactor: move zoo remote wire helpers into @roo-code/core Keeps the extension mutation-diff under the 500 executable-line gate while leaving the vscode-coupled bridge in src. --- packages/core/src/index.ts | 1 + .../zoo-remote-control/ZooRemoteControlClient.ts | 2 +- .../__tests__/ZooRemoteControlClient.spec.ts | 0 .../zoo-remote-control/__tests__/eligibility.spec.ts | 0 .../__tests__/projectMessage.spec.ts | 0 .../core/src}/zoo-remote-control/eligibility.ts | 0 packages/core/src/zoo-remote-control/index.ts | 4 ++++ .../core/src}/zoo-remote-control/projectMessage.ts | 0 .../core/src}/zoo-remote-control/types.ts | 0 packages/types/src/vscode-extension-host.ts | 2 +- .../zoo-remote-control/ZooRemoteControlBridge.ts | 12 ++++++++---- .../__tests__/ZooRemoteControlBridge.spec.ts | 3 ++- 12 files changed, 17 insertions(+), 7 deletions(-) rename {src/services => packages/core/src}/zoo-remote-control/ZooRemoteControlClient.ts (99%) rename {src/services => packages/core/src}/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts (100%) rename {src/services => packages/core/src}/zoo-remote-control/__tests__/eligibility.spec.ts (100%) rename {src/services => packages/core/src}/zoo-remote-control/__tests__/projectMessage.spec.ts (100%) rename {src/services => packages/core/src}/zoo-remote-control/eligibility.ts (100%) create mode 100644 packages/core/src/zoo-remote-control/index.ts rename {src/services => packages/core/src}/zoo-remote-control/projectMessage.ts (100%) rename {src/services => packages/core/src}/zoo-remote-control/types.ts (100%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e63979cf8b..b72dcc0434 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,4 @@ export * from "./debug-log/index.js" export * from "./message-utils/index.js" export * from "./task-history/index.js" export * from "./worktree/index.js" +export * from "./zoo-remote-control/index.js" diff --git a/src/services/zoo-remote-control/ZooRemoteControlClient.ts b/packages/core/src/zoo-remote-control/ZooRemoteControlClient.ts similarity index 99% rename from src/services/zoo-remote-control/ZooRemoteControlClient.ts rename to packages/core/src/zoo-remote-control/ZooRemoteControlClient.ts index 87bc28346e..2819cb0ef2 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlClient.ts +++ b/packages/core/src/zoo-remote-control/ZooRemoteControlClient.ts @@ -5,7 +5,7 @@ import { type ZooRemoteEvent, type ZooRemotePresence, type ZooRemoteSnapshot, -} from "./types" +} from "./types.js" export type ZooRemoteControlClientOptions = { getBaseUrl: () => string diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts b/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts similarity index 100% rename from src/services/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts rename to packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts diff --git a/src/services/zoo-remote-control/__tests__/eligibility.spec.ts b/packages/core/src/zoo-remote-control/__tests__/eligibility.spec.ts similarity index 100% rename from src/services/zoo-remote-control/__tests__/eligibility.spec.ts rename to packages/core/src/zoo-remote-control/__tests__/eligibility.spec.ts diff --git a/src/services/zoo-remote-control/__tests__/projectMessage.spec.ts b/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts similarity index 100% rename from src/services/zoo-remote-control/__tests__/projectMessage.spec.ts rename to packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts diff --git a/src/services/zoo-remote-control/eligibility.ts b/packages/core/src/zoo-remote-control/eligibility.ts similarity index 100% rename from src/services/zoo-remote-control/eligibility.ts rename to packages/core/src/zoo-remote-control/eligibility.ts diff --git a/packages/core/src/zoo-remote-control/index.ts b/packages/core/src/zoo-remote-control/index.ts new file mode 100644 index 0000000000..540dcdebf6 --- /dev/null +++ b/packages/core/src/zoo-remote-control/index.ts @@ -0,0 +1,4 @@ +export * from "./eligibility.js" +export * from "./projectMessage.js" +export * from "./types.js" +export * from "./ZooRemoteControlClient.js" diff --git a/src/services/zoo-remote-control/projectMessage.ts b/packages/core/src/zoo-remote-control/projectMessage.ts similarity index 100% rename from src/services/zoo-remote-control/projectMessage.ts rename to packages/core/src/zoo-remote-control/projectMessage.ts diff --git a/src/services/zoo-remote-control/types.ts b/packages/core/src/zoo-remote-control/types.ts similarity index 100% rename from src/services/zoo-remote-control/types.ts rename to packages/core/src/zoo-remote-control/types.ts diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f6f8162b9b..ea796d7c81 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -665,7 +665,7 @@ export interface WebviewMessage { apiConfiguration?: ProviderSettings images?: string[] bool?: boolean - value?: number + value?: number | boolean | string stepIndex?: number isLaunchAction?: boolean forceShow?: boolean diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index 3b6ecaac15..14c4728fa0 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -1,5 +1,13 @@ import * as vscode from "vscode" +import { + isZooRemoteControlEligible, + projectClineMessageForRemote, + projectClineMessagesForSnapshot, + ZooRemoteControlClient, + type ZooRemoteCommand, + type ZooRemoteEvent, +} from "@roo-code/core" import { providerIdentifiers, RooCodeEventName, @@ -12,10 +20,6 @@ import type { ClineProvider } from "../../core/webview/ClineProvider" import type { Mode } from "../../shared/modes" import { Package } from "../../shared/package" import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../zoo-code-auth" -import { isZooRemoteControlEligible } from "./eligibility" -import { projectClineMessageForRemote, projectClineMessagesForSnapshot } from "./projectMessage" -import { ZooRemoteControlClient } from "./ZooRemoteControlClient" -import type { ZooRemoteCommand, ZooRemoteEvent } from "./types" const PRESENCE_INTERVAL_MS = 15_000 const COMMAND_POLL_INTERVAL_MS = 2_000 diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index 845b7669fc..b7acc7caa6 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -3,8 +3,9 @@ import { EventEmitter } from "events" import { providerIdentifiers, RooCodeEventName } from "@roo-code/types" +import type { ZooRemoteControlClient } from "@roo-code/core" + import { ZooRemoteControlBridge } from "../ZooRemoteControlBridge" -import type { ZooRemoteControlClient } from "../ZooRemoteControlClient" vi.mock("vscode", () => ({ workspace: { From 60bf2c127d6480a5ff3edef6a6e637fffcb21d87 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:12:51 -0600 Subject: [PATCH 10/36] fix: add .js extensions to zoo-remote-control core test imports --- .../zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts | 2 +- .../core/src/zoo-remote-control/__tests__/eligibility.spec.ts | 2 +- .../src/zoo-remote-control/__tests__/projectMessage.spec.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts b/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts index d20b219d7e..ffa56b5c84 100644 --- a/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { providerIdentifiers } from "@roo-code/types" -import { ZooRemoteControlClient } from "../ZooRemoteControlClient" +import { ZooRemoteControlClient } from "../ZooRemoteControlClient.js" describe("ZooRemoteControlClient", () => { let fetchFn: ReturnType diff --git a/packages/core/src/zoo-remote-control/__tests__/eligibility.spec.ts b/packages/core/src/zoo-remote-control/__tests__/eligibility.spec.ts index 2173ae60a3..26d2a757d7 100644 --- a/packages/core/src/zoo-remote-control/__tests__/eligibility.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/eligibility.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { providerIdentifiers } from "@roo-code/types" -import { isZooRemoteControlEligible } from "../eligibility" +import { isZooRemoteControlEligible } from "../eligibility.js" describe("isZooRemoteControlEligible", () => { it("returns true when opted in with an eligible Zoo session", () => { diff --git a/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts b/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts index 392c367a6c..84f812c4d5 100644 --- a/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts @@ -7,7 +7,7 @@ import { REMOTE_MESSAGE_IMAGE_MAX_COUNT, REMOTE_MESSAGE_TEXT_MAX_CHARS, REMOTE_SNAPSHOT_MAX_MESSAGES, -} from "../projectMessage" +} from "../projectMessage.js" describe("projectClineMessageForRemote", () => { it("truncates oversized tool diff payloads while keeping path metadata", () => { From 78863aae72b44bf607a869f373ac14a4aeb28296 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:13:52 -0600 Subject: [PATCH 11/36] fix: enable remote steer via bool on updateVSCodeSetting --- packages/types/src/vscode-extension-host.ts | 2 +- src/core/webview/webviewMessageHandler.ts | 3 ++- webview-ui/src/components/chat/TaskHeader.tsx | 2 +- webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index ea796d7c81..f6f8162b9b 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -665,7 +665,7 @@ export interface WebviewMessage { apiConfiguration?: ProviderSettings images?: string[] bool?: boolean - value?: number | boolean | string + value?: number stepIndex?: number isLaunchAction?: boolean forceShow?: boolean diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f9db303472..4d87a6f9cf 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1948,7 +1948,8 @@ export const webviewMessageHandler = async ( break case "updateVSCodeSetting": { - const { setting, value } = message + const { setting } = message + const value = message.bool ?? message.value if (setting !== undefined && value !== undefined) { if (ALLOWED_VSCODE_SETTINGS.has(setting)) { diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 9f473f31cf..3718ac8539 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -110,7 +110,7 @@ const TaskHeader = ({ vscode.postMessage({ type: "updateVSCodeSetting", setting: "zoo-code.remoteControl.enabled", - value: true, + bool: true, }) } diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 476d3b8e3f..000c33bf98 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -388,7 +388,7 @@ describe("TaskHeader", () => { expect(mockPostMessage).toHaveBeenCalledWith({ type: "updateVSCodeSetting", setting: "zoo-code.remoteControl.enabled", - value: true, + bool: true, }) }) From 7756a0b66791bb6020e7f12ced3fc161fc09e7c4 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:15:42 -0600 Subject: [PATCH 12/36] chore: keep Cursor rules local and out of the repo --- .cursor/rules/git-identity.mdc | 44 ---------------------------------- .gitignore | 4 ++++ 2 files changed, 4 insertions(+), 44 deletions(-) delete mode 100644 .cursor/rules/git-identity.mdc diff --git a/.cursor/rules/git-identity.mdc b/.cursor/rules/git-identity.mdc deleted file mode 100644 index 7a5d7ca1dd..0000000000 --- a/.cursor/rules/git-identity.mdc +++ /dev/null @@ -1,44 +0,0 @@ ---- -description: Confirm JamesRobert20 is the active GitHub/git identity before any git or gh work -alwaysApply: true ---- - -# Git / GitHub identity — JamesRobert20 only - -Before **any** `git` or `gh` operation that creates commits, issues, PRs, comments, or pushes in this repo: - -## Required identity - -- **GitHub login (active `gh` account):** `JamesRobert20` -- **Git author name:** `JamesRobert20` -- **Git author email:** `59908268+JamesRobert20@users.noreply.github.com` (or the matching JamesRobert20 noreply / `jmtendamema@gmail.com` if that is how this machine is configured for JamesRobert20) - -## Preflight (must run first) - -```bash -gh auth status -git config --get user.name -git config --get user.email -``` - -`gh auth status` must show **JamesRobert20** as `Active account: true`. - -## If the wrong account is active - -**Stop.** Do not create issues, PRs, commits, or comments. - -Especially never use the work GitHub account **`jmtendamema`** for this open-source repo (or for any Zoo Code OSS contribution). - -Switch with: - -```bash -gh auth switch --user JamesRobert20 -``` - -Re-check `gh auth status`. Only then continue. - -## Forbidden - -- Using `jmtendamema` (or any non-JamesRobert20 account) for Zoo-Code issues, PRs, reviews, or pushes -- Skipping the preflight because “we already checked earlier in the session” -- Leaving work-account artifacts (issues/comments) without closing or correcting them when discovered diff --git a/.gitignore b/.gitignore index 3961778d5e..aab63b9ca3 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ qdrant_storage/ plans/ roo-cli-*.tar.gz* + +# Local Cursor agent rules/settings (not shared) +.cursor/ + From fadc3b4514f6dc10c04176afa7e5bff96b3a48bf Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:38:34 -0600 Subject: [PATCH 13/36] fix: harden zoo remote control for privacy, persistence, and lifecycle Send workspace basename only, bound nested tool payloads, await identity/seq persistence, ignore stale command polls after dispose, and cover the regression paths CodeRabbit flagged. --- .../__tests__/ZooRemoteControlClient.spec.ts | 26 +++ .../__tests__/projectMessage.spec.ts | 33 +++ .../src/zoo-remote-control/projectMessage.ts | 50 +++-- .../ZooRemoteControlBridge.ts | 193 +++++++++++++++--- .../__tests__/ZooRemoteControlBridge.spec.ts | 160 ++++++++++++++- 5 files changed, 407 insertions(+), 55 deletions(-) diff --git a/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts b/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts index ffa56b5c84..48f8aab28f 100644 --- a/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts @@ -105,6 +105,32 @@ describe("ZooRemoteControlClient", () => { ) }) + it("returns status 0 when getCommands JSON body is malformed", async () => { + fetchFn.mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError("Unexpected token") + }, + }) + const result = await client.getCommands("inst-1") + expect(result).toEqual({ ok: false, status: 0 }) + expect(log).toHaveBeenCalledWith(expect.stringContaining("Unexpected token")) + }) + + it("returns status 0 when getCommands body fails schema validation", async () => { + fetchFn.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + commands: [{ commandId: "c1", type: "not_a_real_command" }], + }), + }) + const result = await client.getCommands("inst-1") + expect(result).toEqual({ ok: false, status: 0 }) + expect(log).toHaveBeenCalled() + }) + it("acks commands", async () => { fetchFn.mockResolvedValue({ ok: true, status: 200 }) await client.ackCommand({ commandId: "c1", status: "acked" }) diff --git a/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts b/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts index 84f812c4d5..f4e54c7a26 100644 --- a/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts @@ -92,6 +92,39 @@ describe("projectClineMessageForRemote", () => { ).toContain("[truncated") }) + it("recursively truncates nested tool JSON strings beyond top-level keys", () => { + const huge = "n".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 80) + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "updateTodoList", + path: "src/a.ts", + meta: { + summary: huge, + nested: { note: huge, keep: "ok" }, + }, + todos: [ + { id: "1", content: huge }, + { id: "2", content: "short" }, + ], + }), + }) + const parsed = JSON.parse(shaped.text!) as { + path: string + meta: { summary: string; nested: { note: string; keep: string } } + todos: Array<{ id: string; content: string }> + } + expect(parsed.path).toBe("src/a.ts") + expect(parsed.meta.nested.keep).toBe("ok") + expect(parsed.meta.summary).toContain("[truncated") + expect(parsed.meta.nested.note).toContain("[truncated") + expect(parsed.todos[0]?.content).toContain("[truncated") + expect(parsed.todos[1]?.content).toBe("short") + expect(parsed.meta.summary.length).toBeLessThanOrEqual(REMOTE_MESSAGE_TEXT_MAX_CHARS + 40) + }) + it("shapes batchDiffs entries and leaves non-object batch entries untouched", () => { const huge = "d".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 10) const shaped = projectClineMessageForRemote({ diff --git a/packages/core/src/zoo-remote-control/projectMessage.ts b/packages/core/src/zoo-remote-control/projectMessage.ts index 356f0a53d4..63d29c885b 100644 --- a/packages/core/src/zoo-remote-control/projectMessage.ts +++ b/packages/core/src/zoo-remote-control/projectMessage.ts @@ -5,6 +5,8 @@ export const REMOTE_MESSAGE_TEXT_MAX_CHARS = 48_000 export const REMOTE_MESSAGE_IMAGE_MAX_COUNT = 2 export const REMOTE_MESSAGE_IMAGE_MAX_CHARS = 8_000 export const REMOTE_SNAPSHOT_MAX_MESSAGES = 80 +/** Cap nested arrays (e.g. batchDiffs) so snapshots stay bounded. */ +export const REMOTE_MESSAGE_ARRAY_MAX_ITEMS = 20 function truncateString(value: string, max: number): string { if (value.length <= max) { @@ -13,38 +15,34 @@ function truncateString(value: string, max: number): string { return `${value.slice(0, max)}\n…[truncated ${value.length - max} chars]` } +function boundJsonValue(value: unknown): unknown { + if (typeof value === "string") { + return truncateString(value, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } + + if (Array.isArray(value)) { + return value.slice(0, REMOTE_MESSAGE_ARRAY_MAX_ITEMS).map((entry) => boundJsonValue(entry)) + } + + if (value && typeof value === "object") { + const record: Record = {} + for (const [key, child] of Object.entries(value as Record)) { + record[key] = boundJsonValue(child) + } + return record + } + + return value +} + function shapeToolText(text: string): string { try { const parsed: unknown = JSON.parse(text) - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (parsed === null || typeof parsed !== "object") { return truncateString(text, REMOTE_MESSAGE_TEXT_MAX_CHARS) } - const record = { ...(parsed as Record) } - for (const key of ["content", "diff", "originalContent", "code", "result"] as const) { - const value = record[key] - if (typeof value === "string") { - record[key] = truncateString(value, REMOTE_MESSAGE_TEXT_MAX_CHARS) - } - } - - if (Array.isArray(record.batchDiffs)) { - record.batchDiffs = record.batchDiffs.slice(0, 20).map((entry) => { - if (!entry || typeof entry !== "object") { - return entry - } - const item = { ...(entry as Record) } - if (typeof item.diff === "string") { - item.diff = truncateString(item.diff, REMOTE_MESSAGE_TEXT_MAX_CHARS) - } - if (typeof item.content === "string") { - item.content = truncateString(item.content, REMOTE_MESSAGE_TEXT_MAX_CHARS) - } - return item - }) - } - - return JSON.stringify(record) + return JSON.stringify(boundJsonValue(parsed)) } catch { return truncateString(text, REMOTE_MESSAGE_TEXT_MAX_CHARS) } diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index 14c4728fa0..1234b5bee5 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -57,6 +57,14 @@ export class ZooRemoteControlBridge implements vscode.Disposable { private pendingEvents: ZooRemoteEvent[] = [] private lastPartialFlushAt = 0 private pendingPartials = new Map() + /** Resolves true once instance id is durable in globalState (or already was). */ + private readonly identityReady: Promise + /** Serializes event-seq writes so updates cannot complete out of order. */ + private eventSeqPersistChain: Promise = Promise.resolve() + private eventSeqPersistFailed = false + /** Bumped on deactivate/dispose so in-flight polls/flushes become no-ops. */ + private lifecycleEpoch = 0 + private commandPollInFlight = false private presenceTimer: ReturnType | undefined private commandTimer: ReturnType | undefined @@ -101,7 +109,9 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.context = deps.context this.log = deps.log ?? (() => {}) this.now = deps.now ?? (() => Date.now()) - this.instanceId = this.resolveInstanceId() + const identity = this.resolveInstanceId() + this.instanceId = identity.id + this.identityReady = identity.needsPersist ? this.persistInstanceId(identity.id) : Promise.resolve(true) this.eventSeq = this.resolveEventSeq() this.client = deps.client ?? @@ -140,6 +150,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { public dispose(): void { this.disposed = true + this.lifecycleEpoch += 1 void this.deactivate({ notifyOffline: true }) if (typeof this.provider.off === "function") { this.provider.off(RooCodeEventName.ProviderProfileChanged, this.onProviderProfileChanged) @@ -150,6 +161,10 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.disposables.length = 0 } + private isLifecycleCurrent(epoch: number): boolean { + return !this.disposed && this.active && epoch === this.lifecycleEpoch + } + /** Test helper */ public isActive(): boolean { return this.active @@ -160,10 +175,10 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return this.instanceId } - private resolveInstanceId(): string { + private resolveInstanceId(): { id: string; needsPersist: boolean } { const existing = this.context.globalState.get(INSTANCE_ID_STATE_KEY) if (existing) { - return existing + return { id: existing, needsPersist: false } } const generated = @@ -171,8 +186,19 @@ export class ZooRemoteControlBridge implements vscode.Disposable { ? vscode.env.sessionId : `zoo-${this.now()}-${Math.random().toString(36).slice(2, 10)}` - void this.context.globalState.update(INSTANCE_ID_STATE_KEY, generated) - return generated + return { id: generated, needsPersist: true } + } + + private async persistInstanceId(id: string): Promise { + try { + await this.context.globalState.update(INSTANCE_ID_STATE_KEY, id) + return true + } catch (error) { + this.log( + `[ZooRemoteControl] failed to persist instance id: ${error instanceof Error ? error.message : String(error)}`, + ) + return false + } } private resolveEventSeq(): number { @@ -181,7 +207,20 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private persistEventSeq(): void { - void this.context.globalState.update(EVENT_SEQ_STATE_KEY, this.eventSeq) + this.eventSeqPersistChain = this.eventSeqPersistChain + .then(async () => { + if (this.disposed || this.eventSeqPersistFailed) { + return + } + await this.context.globalState.update(EVENT_SEQ_STATE_KEY, this.eventSeq) + }) + .catch((error: unknown) => { + this.eventSeqPersistFailed = true + this.pendingEvents = [] + this.log( + `[ZooRemoteControl] failed to persist event seq: ${error instanceof Error ? error.message : String(error)}`, + ) + }) } private isSettingEnabled(): boolean { @@ -233,6 +272,12 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } + const identityOk = await this.identityReady + if (!identityOk || this.disposed) { + this.log("[ZooRemoteControl] refusing to activate: instance id is not persisted") + return + } + this.active = true this.serverAvailable = true this.unavailableUntil = 0 @@ -259,6 +304,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } + this.lifecycleEpoch += 1 this.active = false this.clearTimers() this.bindApiListeners(false) @@ -321,6 +367,12 @@ export class ZooRemoteControlBridge implements vscode.Disposable { const isPartial = shaped.message.partial === true if (isPartial) { this.pendingPartials.set(this.messageKey(shaped.taskId, shaped.message), shaped) + // Start the throttle window on the first partial without flushing so rapid + // updates to the same message coalesce to the latest text. + if (this.lastPartialFlushAt === 0) { + this.lastPartialFlushAt = this.now() + return + } const elapsed = this.now() - this.lastPartialFlushAt if (elapsed < PARTIAL_EVENT_MIN_INTERVAL_MS) { return @@ -362,7 +414,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private enqueueEvent(event: Omit): void { - if (!this.active) { + if (!this.active || this.eventSeqPersistFailed) { return } @@ -395,8 +447,17 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + // Nickname only — never send an absolute disk path (home dir / username fingerprint). + // Split on both separators so Windows paths stay safe when unit tests run on Linux CI. + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + const workspacePath = workspaceRoot + ? workspaceRoot + .replace(/[/\\]+$/, "") + .split(/[/\\]/) + .pop() || undefined + : undefined const taskId = this.provider.getCurrentTask()?.taskId + const epoch = this.lifecycleEpoch const result = await this.client.postPresence({ instanceId: this.instanceId, apiProvider: this.getApiProvider() ?? providerIdentifiers.zooGateway, @@ -407,6 +468,16 @@ export class ZooRemoteControlBridge implements vscode.Disposable { extensionVersion: Package.version, }) + // Online publishes must still belong to the active lifecycle. Offline notify is allowed + // after deactivate (active=false) as long as a newer activation has not started. + if (online) { + if (!this.isLifecycleCurrent(epoch)) { + return + } + } else if (this.active && epoch !== this.lifecycleEpoch) { + return + } + if (result.status === 401 || result.status === 402 || result.status === 403) { this.markNotEligibleBackoff() if (this.active) { @@ -465,10 +536,11 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private async flushEvents(): Promise { - if (!this.active || !this.canCallServer()) { + if (!this.active || this.eventSeqPersistFailed || !this.canCallServer()) { return } + const epoch = this.lifecycleEpoch this.flushPendingPartials() if (this.pendingEvents.length === 0) { return @@ -476,6 +548,9 @@ export class ZooRemoteControlBridge implements vscode.Disposable { const batch = this.pendingEvents.splice(0, this.pendingEvents.length) const result = await this.client.postEvents(this.instanceId, batch) + if (!this.isLifecycleCurrent(epoch)) { + return + } if (result.status === 401 || result.status === 402 || result.status === 403) { this.markNotEligibleBackoff() @@ -498,7 +573,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private async pollCommands(): Promise { - if (!this.active || !this.canCallServer()) { + if (!this.active || this.disposed || this.commandPollInFlight || !this.canCallServer()) { return } @@ -507,32 +582,51 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - const result = await this.client.getCommands(this.instanceId) + this.commandPollInFlight = true + const epoch = this.lifecycleEpoch + try { + const result = await this.client.getCommands(this.instanceId) + if (!this.isLifecycleCurrent(epoch)) { + return + } - if (result.status === 401 || result.status === 402 || result.status === 403) { - this.markNotEligibleBackoff() - await this.deactivate({ notifyOffline: false }) - return - } + if (result.status === 401 || result.status === 402 || result.status === 403) { + this.markNotEligibleBackoff() + await this.deactivate({ notifyOffline: false }) + return + } - if (result.status === 404) { - this.markUnavailable() - return - } + if (result.status === 404) { + this.markUnavailable() + return + } - if (!result.ok || !result.body) { - return - } + if (!result.ok || !result.body) { + return + } - this.serverAvailable = true + this.serverAvailable = true - for (const command of result.body.commands) { - await this.executeCommand(command) + for (const command of result.body.commands) { + if (!this.isLifecycleCurrent(epoch)) { + return + } + await this.executeCommand(command, epoch) + } + } finally { + this.commandPollInFlight = false } } - private async executeCommand(command: ZooRemoteCommand): Promise { + private async executeCommand(command: ZooRemoteCommand, epoch: number): Promise { + if (!this.isLifecycleCurrent(epoch)) { + return + } + if (!this.evaluateEligible()) { + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.client.ackCommand({ commandId: command.commandId, status: "failed", @@ -545,14 +639,23 @@ export class ZooRemoteControlBridge implements vscode.Disposable { switch (command.type) { case "message": case "answer_followup": + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.api.sendMessage(command.payload?.text, command.payload?.images) if (command.payload?.mode) { + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.provider.handleModeSwitch(command.payload.mode as Mode) } break case "approve_ask": { const task = this.provider.getCurrentTask() if (!task) { + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.client.ackCommand({ commandId: command.commandId, status: "failed", @@ -560,12 +663,18 @@ export class ZooRemoteControlBridge implements vscode.Disposable { }) return } + if (!this.isLifecycleCurrent(epoch)) { + return + } task.approveAsk({ text: command.payload?.text, images: command.payload?.images }) break } case "deny_ask": { const task = this.provider.getCurrentTask() if (!task) { + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.client.ackCommand({ commandId: command.commandId, status: "failed", @@ -573,14 +682,23 @@ export class ZooRemoteControlBridge implements vscode.Disposable { }) return } + if (!this.isLifecycleCurrent(epoch)) { + return + } task.denyAsk({ text: command.payload?.text, images: command.payload?.images }) break } case "cancel_task": + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.api.cancelCurrentTask() break case "resume_task": if (!command.taskId) { + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.client.ackCommand({ commandId: command.commandId, status: "failed", @@ -588,9 +706,15 @@ export class ZooRemoteControlBridge implements vscode.Disposable { }) return } + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.api.resumeTask(command.taskId) break case "start_task": + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.api.startNewTask({ text: command.payload?.text, images: command.payload?.images, @@ -599,6 +723,9 @@ export class ZooRemoteControlBridge implements vscode.Disposable { case "switch_mode": { const mode = command.payload?.mode if (!mode) { + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.client.ackCommand({ commandId: command.commandId, status: "failed", @@ -606,11 +733,17 @@ export class ZooRemoteControlBridge implements vscode.Disposable { }) return } + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.provider.handleModeSwitch(mode as Mode) break } default: { const _exhaustive: never = command.type + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.client.ackCommand({ commandId: command.commandId, status: "failed", @@ -621,9 +754,15 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } } + if (!this.isLifecycleCurrent(epoch)) { + return + } // Ack 404 (command_not_found) must not mark the bridge unavailable. await this.client.ackCommand({ commandId: command.commandId, status: "acked" }) } catch (error) { + if (!this.isLifecycleCurrent(epoch)) { + return + } await this.client.ackCommand({ commandId: command.commandId, status: "failed", diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index b7acc7caa6..58e54f849d 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -141,11 +141,41 @@ describe("ZooRemoteControlBridge", () => { instanceId: "instance-1", apiProvider: providerIdentifiers.zooGateway, online: true, + workspacePath: "project", }), ) + const presence = client.postPresence.mock.calls[0]?.[0] as { workspacePath?: string } + expect(presence.workspacePath).toBe("project") + expect(presence.workspacePath).not.toMatch(/[/\\]/) bridge.dispose() }) + it("posts only the workspace folder basename, never an absolute path", async () => { + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + ;(vscode.workspace as { workspaceFolders: { uri: { fsPath: string } }[] }).workspaceFolders = [ + { uri: { fsPath: "C:\\Users\\alice\\Desktop\\secret-client" } }, + ] + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + + expect(client.postPresence).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePath: "secret-client", + }), + ) + const presence = client.postPresence.mock.calls[0]?.[0] as { workspacePath?: string } + expect(presence.workspacePath).not.toMatch(/[/\\]|Users|alice/i) + bridge.dispose() + ;(vscode.workspace as { workspaceFolders: { uri: { fsPath: string } }[] }).workspaceFolders = [ + { uri: { fsPath: "/tmp/project" } }, + ] + }) + it("does not activate when the provider is not eligible", async () => { getConfigValue = () => true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") @@ -628,10 +658,17 @@ describe("ZooRemoteControlBridge", () => { message: { ts: 1, type: "say", say: "text", text: "ab", partial: true, messageId: "p1" }, }) await vi.advanceTimersByTimeAsync(50) - const earlyCalls = client.postEvents.mock.calls.length + expect(client.postEvents).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(100) - expect(client.postEvents.mock.calls.length).toBeGreaterThanOrEqual(earlyCalls) + expect(client.postEvents).toHaveBeenCalledTimes(1) + expect(client.postEvents).toHaveBeenCalledWith("instance-1", [ + expect.objectContaining({ + type: "message", + taskId: "task-1", + message: expect.objectContaining({ text: "ab", partial: true, messageId: "p1" }), + }), + ]) bridge.dispose() }) @@ -719,6 +756,68 @@ describe("ZooRemoteControlBridge", () => { await vi.advanceTimersByTimeAsync(0) expect(bridge.getInstanceId()).toBe("test-session-id") expect(context.globalState.update).toHaveBeenCalledWith("zooRemoteControl.instanceId", "test-session-id") + expect(bridge.isActive()).toBe(true) + bridge.dispose() + }) + + it("does not activate when instance id persistence fails", async () => { + context.globalState.get.mockImplementation((key: string) => { + if (key === "zooRemoteControl.eventSeq") return 0 + return undefined + }) + context.globalState.update.mockImplementation(async (key: string) => { + if (key === "zooRemoteControl.instanceId") { + throw new Error("storage unavailable") + } + }) + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(false) + expect(client.postPresence).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("awaits serialized event-seq persistence and stops enqueueing after persist failure", async () => { + getConfigValue = () => true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + let rejectSeq: (error: Error) => void = () => {} + const seqWrite = new Promise((_resolve, reject) => { + rejectSeq = reject + }) + context.globalState.update.mockImplementation((key: string) => { + if (key === "zooRemoteControl.eventSeq") { + return seqWrite + } + return Promise.resolve() + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(true) + + api.emit(RooCodeEventName.TaskIdle, "task-1") + await Promise.resolve() + await Promise.resolve() + expect(context.globalState.update).toHaveBeenCalledWith("zooRemoteControl.eventSeq", expect.any(Number)) + + rejectSeq(new Error("seq write failed")) + await Promise.resolve() + await Promise.resolve() + + client.postEvents.mockClear() + api.emit(RooCodeEventName.TaskIdle, "task-2") + await vi.advanceTimersByTimeAsync(200) + expect(client.postEvents).not.toHaveBeenCalled() bridge.dispose() }) @@ -765,6 +864,63 @@ describe("ZooRemoteControlBridge", () => { bridge.dispose() }) + it("ignores command poll results after dispose", async () => { + let resolveCommands: (value: { + ok: boolean + status: number + body: { commands: Array<{ commandId: string; type: string; payload?: { text?: string } }> } + }) => void = () => {} + client.getCommands.mockImplementation( + () => + new Promise((resolve) => { + resolveCommands = resolve + }), + ) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + expect(client.getCommands).toHaveBeenCalledTimes(1) + + bridge.dispose() + resolveCommands({ + ok: true, + status: 200, + body: { + commands: [{ commandId: "late-1", type: "message", payload: { text: "too late" } }], + }, + }) + await Promise.resolve() + await Promise.resolve() + + expect(api.sendMessage).not.toHaveBeenCalled() + expect(client.ackCommand).not.toHaveBeenCalled() + }) + + it("does not start overlapping command polls while one is in flight", async () => { + let resolveCommands: (value: { ok: boolean; status: number; body: { commands: [] } }) => void = () => {} + client.getCommands.mockImplementation( + () => + new Promise((resolve) => { + resolveCommands = resolve + }), + ) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + expect(client.getCommands).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(2_000) + expect(client.getCommands).toHaveBeenCalledTimes(1) + + resolveCommands({ ok: true, status: 200, body: { commands: [] } }) + await Promise.resolve() + await Promise.resolve() + + await vi.advanceTimersByTimeAsync(2_000) + expect(client.getCommands).toHaveBeenCalledTimes(2) + bridge.dispose() + }) + it("acks unknown command types as failed", async () => { client.getCommands.mockResolvedValueOnce({ ok: true, From 3ad2b753f68b0959799be2ccededecb41116b6cf Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:39:51 -0600 Subject: [PATCH 14/36] fix: cast workspaceFolders mock through unknown for tsc --- .../__tests__/ZooRemoteControlBridge.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index 58e54f849d..7298de191e 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -156,7 +156,7 @@ describe("ZooRemoteControlBridge", () => { provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, }) - ;(vscode.workspace as { workspaceFolders: { uri: { fsPath: string } }[] }).workspaceFolders = [ + ;(vscode.workspace as unknown as { workspaceFolders: { uri: { fsPath: string } }[] }).workspaceFolders = [ { uri: { fsPath: "C:\\Users\\alice\\Desktop\\secret-client" } }, ] @@ -171,7 +171,7 @@ describe("ZooRemoteControlBridge", () => { const presence = client.postPresence.mock.calls[0]?.[0] as { workspacePath?: string } expect(presence.workspacePath).not.toMatch(/[/\\]|Users|alice/i) bridge.dispose() - ;(vscode.workspace as { workspaceFolders: { uri: { fsPath: string } }[] }).workspaceFolders = [ + ;(vscode.workspace as unknown as { workspaceFolders: { uri: { fsPath: string } }[] }).workspaceFolders = [ { uri: { fsPath: "/tmp/project" } }, ] }) From 6a71087345b6081cf29ec98ccd14c0c6342632e7 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:47:45 -0600 Subject: [PATCH 15/36] fix: make remote steer an immediate manual task control Replace the delayed step-away upsell with a always-visible header action so users can deliberately enable remote steer on any open Zoo Gateway task, including completed ones. --- webview-ui/src/components/chat/TaskHeader.tsx | 61 ++++++------------- .../chat/__tests__/TaskHeader.spec.tsx | 20 +++--- webview-ui/src/i18n/locales/en/chat.json | 3 +- 3 files changed, 26 insertions(+), 58 deletions(-) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 3718ac8539..9817efc914 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef, useState, useMemo } from "react" +import { memo, useRef, useState, useMemo } from "react" import { useTranslation } from "react-i18next" import { ChevronUp, @@ -14,7 +14,6 @@ import prettyBytes from "pretty-bytes" import { providerIdentifiers, type ClineMessage } from "@roo-code/types" import { getModelMaxOutputTokens } from "@roo/api" -import { findLastIndex } from "@roo/array" import { formatLargeNumber } from "@src/utils/format" import { cn } from "@src/lib/utils" @@ -22,7 +21,6 @@ import { StandardTooltip, Button, Table, TableBody, TableRow, TableCell, Circula import { useExtensionState } from "@src/context/ExtensionStateContext" import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" import { vscode } from "@src/utils/vscode" -import DismissibleUpsell from "@src/components/common/DismissibleUpsell" import Thumbnails from "../common/Thumbnails" import { TaskActions } from "./TaskActions" @@ -65,17 +63,10 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { - apiConfiguration, - currentTaskItem, - clineMessages, - zooCodeIsAuthenticated, - zooCodeBaseUrl, - remoteControlEnabled, - } = useExtensionState() + const { apiConfiguration, currentTaskItem, zooCodeIsAuthenticated, zooCodeBaseUrl, remoteControlEnabled } = + useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) - const [showRemoteSteerOffer, setShowRemoteSteerOffer] = useState(false) const textContainerRef = useRef(null) const textRef = useRef(null) @@ -84,28 +75,6 @@ const TaskHeader = ({ const isZooGateway = apiConfiguration?.apiProvider === providerIdentifiers.zooGateway const canOfferRemoteSteer = Boolean(zooCodeIsAuthenticated && isZooGateway && currentTaskItem) - const isTaskComplete = - clineMessages && clineMessages.length > 0 - ? (() => { - const lastRelevantIndex = findLastIndex( - clineMessages, - (msg) => !(msg.ask === "resume_task" || msg.ask === "resume_completed_task"), - ) - const last = lastRelevantIndex >= 0 ? clineMessages[lastRelevantIndex] : undefined - return last?.ask === "completion_result" || last?.say === "completion_result" - })() - : false - - useEffect(() => { - if (!canOfferRemoteSteer || remoteControlEnabled || isTaskComplete) { - setShowRemoteSteerOffer(false) - return - } - // Match Roo's long-running-task cadence: offer after the task has been going a bit. - const timer = setTimeout(() => setShowRemoteSteerOffer(true), 120_000) - return () => clearTimeout(timer) - }, [canOfferRemoteSteer, remoteControlEnabled, isTaskComplete, currentTaskItem?.id]) - const enableRemoteSteer = () => { vscode.postMessage({ type: "updateVSCodeSetting", @@ -190,10 +159,11 @@ const TaskHeader = ({
)} - {canOfferRemoteSteer && remoteControlEnabled && !isTaskComplete && ( + {canOfferRemoteSteer && remoteControlEnabled && (
e.stopPropagation()}> + onClick={(e) => e.stopPropagation()} + data-testid="remote-steer-active"> {t("chat:task.steerRemotelyActive")}
)} - {showRemoteSteerOffer && !isTaskComplete && ( -
e.stopPropagation()}> - + {canOfferRemoteSteer && !remoteControlEnabled && ( +
e.stopPropagation()} + data-testid="remote-steer-offer"> + {t("chat:task.steerRemotelyHint")} +
)}
{ }) }) - describe("remote steer banner", () => { + describe("remote steer control", () => { beforeEach(() => { - vi.useFakeTimers() mockPostMessage.mockClear() mockExtensionState.apiConfiguration = { apiProvider: providerIdentifiers.zooGateway, } as ProviderSettings mockExtensionState.zooCodeIsAuthenticated = true mockExtensionState.remoteControlEnabled = false - mockExtensionState.clineMessages = [{ type: "say", say: "text", ts: 1, text: "working" }] + mockExtensionState.clineMessages = [{ type: "say", say: "completion_result", ts: 1, text: "done" }] }) afterEach(() => { - vi.useRealTimers() mockExtensionState.apiConfiguration = { apiProvider: providerIdentifiers.anthropic, apiKey: "test-api-key", @@ -376,15 +374,11 @@ describe("TaskHeader", () => { mockExtensionState.clineMessages = [] }) - it("offers remote steer after two minutes and enables it on click", async () => { + it("shows an immediate manual control to enable remote steer, including on completed tasks", () => { renderTaskHeader() - expect(screen.queryByTestId("upsell-zooRemoteSteer")).not.toBeInTheDocument() - - await vi.advanceTimersByTimeAsync(120_000) - const offer = screen.getByTestId("upsell-zooRemoteSteer") - expect(offer).toHaveTextContent("chat:task.steerRemotely") - - fireEvent.click(offer) + const offer = screen.getByTestId("remote-steer-offer") + expect(offer).toHaveTextContent("chat:task.steerRemotelyHint") + fireEvent.click(screen.getByRole("button", { name: "chat:task.steerRemotely" })) expect(mockPostMessage).toHaveBeenCalledWith({ type: "updateVSCodeSetting", setting: "zoo-code.remoteControl.enabled", @@ -397,7 +391,7 @@ describe("TaskHeader", () => { mockExtensionState.zooCodeBaseUrl = "https://www.zoocode.dev" renderTaskHeader() - expect(screen.getByText("chat:task.steerRemotelyActive")).toBeInTheDocument() + expect(screen.getByTestId("remote-steer-active")).toHaveTextContent("chat:task.steerRemotelyActive") fireEvent.click(screen.getByText("chat:task.openRemoteDashboard")) expect(mockPostMessage).toHaveBeenCalledWith({ type: "openExternal", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 67b29ff984..52404d952c 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -20,7 +20,8 @@ "backToParentTask": "Parent task", "waitingOnSubtask": "Waiting on subtask", "goToSubtask": "Go to subtask", - "steerRemotely": "Need to step away? Steer this task remotely from the Zoo dashboard.", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Mirror this task to the Zoo dashboard so you can approve, reply, and continue from there.", "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", "openRemoteDashboard": "Open dashboard" }, From 91661327fc6dcf260e20dc0abe8fa09a6a2fb3f9 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:58:16 -0600 Subject: [PATCH 16/36] fix: shrink remote-control mutation scope and clarify steer UX Move command execution into core so the extension stays under the mutation-diff line budget. Steering enables remote control and opens the dashboard; non-Zoo Gateway profiles get an IDE prompt to switch. --- .../__tests__/executeRemoteCommand.spec.ts | 53 ++++++ .../executeRemoteCommand.ts | 180 ++++++++++++++++++ packages/core/src/zoo-remote-control/index.ts | 1 + .../ZooRemoteControlBridge.ts | 168 ++-------------- webview-ui/src/components/chat/TaskHeader.tsx | 41 +++- .../chat/__tests__/TaskHeader.spec.tsx | 20 ++ webview-ui/src/i18n/locales/en/chat.json | 2 + 7 files changed, 303 insertions(+), 162 deletions(-) create mode 100644 packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts create mode 100644 packages/core/src/zoo-remote-control/executeRemoteCommand.ts diff --git a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts new file mode 100644 index 0000000000..2c8a1a8770 --- /dev/null +++ b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest" + +import { executeRemoteCommand } from "../executeRemoteCommand.js" + +describe("executeRemoteCommand", () => { + it("acks and sends a message when eligible and lifecycle is current", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const sendMessage = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", payload: { text: "hi" } }, + epoch: 1, + client: { ackCommand }, + host: { + isLifecycleCurrent: () => true, + isEligible: () => true, + sendMessage, + handleModeSwitch: vi.fn(), + getCurrentTask: () => undefined, + cancelCurrentTask: vi.fn(), + resumeTask: vi.fn(), + startNewTask: vi.fn(), + }, + }) + + expect(sendMessage).toHaveBeenCalledWith("hi", undefined) + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("skips host actions when lifecycle is stale", async () => { + const ackCommand = vi.fn() + const sendMessage = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", payload: { text: "hi" } }, + epoch: 1, + client: { ackCommand }, + host: { + isLifecycleCurrent: () => false, + isEligible: () => true, + sendMessage, + handleModeSwitch: vi.fn(), + getCurrentTask: () => undefined, + cancelCurrentTask: vi.fn(), + resumeTask: vi.fn(), + startNewTask: vi.fn(), + }, + }) + + expect(sendMessage).not.toHaveBeenCalled() + expect(ackCommand).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts new file mode 100644 index 0000000000..3cd3a61ff4 --- /dev/null +++ b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts @@ -0,0 +1,180 @@ +import type { ZooRemoteCommand } from "./types.js" +import type { ZooRemoteControlClient } from "./ZooRemoteControlClient.js" + +export type ZooRemoteCommandHost = { + isLifecycleCurrent: (epoch: number) => boolean + isEligible: () => boolean + sendMessage: (text?: string, images?: string[]) => Promise + handleModeSwitch: (mode: string) => Promise + getCurrentTask: () => + | { + approveAsk: (payload?: { text?: string; images?: string[] }) => void + denyAsk: (payload?: { text?: string; images?: string[] }) => void + } + | undefined + cancelCurrentTask: () => Promise + resumeTask: (taskId: string) => Promise + startNewTask: (payload: { text?: string; images?: string[] }) => Promise +} + +/** Apply one remote command through host callbacks, respecting lifecycle epoch checks. */ +export async function executeRemoteCommand(options: { + command: ZooRemoteCommand + epoch: number + client: Pick + host: ZooRemoteCommandHost +}): Promise { + const { command, epoch, client, host } = options + + if (!host.isLifecycleCurrent(epoch)) { + return + } + + if (!host.isEligible()) { + if (!host.isLifecycleCurrent(epoch)) { + return + } + await client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "not_eligible", + }) + return + } + + try { + switch (command.type) { + case "message": + case "answer_followup": + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.sendMessage(command.payload?.text, command.payload?.images) + if (command.payload?.mode) { + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.handleModeSwitch(command.payload.mode) + } + break + case "approve_ask": { + const task = host.getCurrentTask() + if (!task) { + if (!host.isLifecycleCurrent(epoch)) { + return + } + await client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "no_current_task", + }) + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + task.approveAsk({ text: command.payload?.text, images: command.payload?.images }) + break + } + case "deny_ask": { + const task = host.getCurrentTask() + if (!task) { + if (!host.isLifecycleCurrent(epoch)) { + return + } + await client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "no_current_task", + }) + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + task.denyAsk({ text: command.payload?.text, images: command.payload?.images }) + break + } + case "cancel_task": + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.cancelCurrentTask() + break + case "resume_task": + if (!command.taskId) { + if (!host.isLifecycleCurrent(epoch)) { + return + } + await client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "missing_task_id", + }) + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.resumeTask(command.taskId) + break + case "start_task": + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.startNewTask({ + text: command.payload?.text, + images: command.payload?.images, + }) + break + case "switch_mode": { + const mode = command.payload?.mode + if (!mode) { + if (!host.isLifecycleCurrent(epoch)) { + return + } + await client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "missing_mode", + }) + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.handleModeSwitch(mode) + break + } + default: { + const _exhaustive: never = command.type + if (!host.isLifecycleCurrent(epoch)) { + return + } + await client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "unknown_command", + message: String(_exhaustive), + }) + return + } + } + + if (!host.isLifecycleCurrent(epoch)) { + return + } + // Ack 404 (command_not_found) must not mark the bridge unavailable. + await client.ackCommand({ commandId: command.commandId, status: "acked" }) + } catch (error) { + if (!host.isLifecycleCurrent(epoch)) { + return + } + await client.ackCommand({ + commandId: command.commandId, + status: "failed", + errorCode: "execution_failed", + message: error instanceof Error ? error.message : String(error), + }) + } +} diff --git a/packages/core/src/zoo-remote-control/index.ts b/packages/core/src/zoo-remote-control/index.ts index 540dcdebf6..c12bcad4cd 100644 --- a/packages/core/src/zoo-remote-control/index.ts +++ b/packages/core/src/zoo-remote-control/index.ts @@ -1,4 +1,5 @@ export * from "./eligibility.js" +export * from "./executeRemoteCommand.js" export * from "./projectMessage.js" export * from "./types.js" export * from "./ZooRemoteControlClient.js" diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index 1234b5bee5..3bd536e765 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import { + executeRemoteCommand, isZooRemoteControlEligible, projectClineMessageForRemote, projectClineMessagesForSnapshot, @@ -17,7 +18,6 @@ import { } from "@roo-code/types" import type { ClineProvider } from "../../core/webview/ClineProvider" -import type { Mode } from "../../shared/modes" import { Package } from "../../shared/package" import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../zoo-code-auth" @@ -619,156 +619,20 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private async executeCommand(command: ZooRemoteCommand, epoch: number): Promise { - if (!this.isLifecycleCurrent(epoch)) { - return - } - - if (!this.evaluateEligible()) { - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "not_eligible", - }) - return - } - - try { - switch (command.type) { - case "message": - case "answer_followup": - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.api.sendMessage(command.payload?.text, command.payload?.images) - if (command.payload?.mode) { - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.provider.handleModeSwitch(command.payload.mode as Mode) - } - break - case "approve_ask": { - const task = this.provider.getCurrentTask() - if (!task) { - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "no_current_task", - }) - return - } - if (!this.isLifecycleCurrent(epoch)) { - return - } - task.approveAsk({ text: command.payload?.text, images: command.payload?.images }) - break - } - case "deny_ask": { - const task = this.provider.getCurrentTask() - if (!task) { - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "no_current_task", - }) - return - } - if (!this.isLifecycleCurrent(epoch)) { - return - } - task.denyAsk({ text: command.payload?.text, images: command.payload?.images }) - break - } - case "cancel_task": - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.api.cancelCurrentTask() - break - case "resume_task": - if (!command.taskId) { - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "missing_task_id", - }) - return - } - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.api.resumeTask(command.taskId) - break - case "start_task": - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.api.startNewTask({ - text: command.payload?.text, - images: command.payload?.images, - }) - break - case "switch_mode": { - const mode = command.payload?.mode - if (!mode) { - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "missing_mode", - }) - return - } - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.provider.handleModeSwitch(mode as Mode) - break - } - default: { - const _exhaustive: never = command.type - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "unknown_command", - message: String(_exhaustive), - }) - return - } - } - - if (!this.isLifecycleCurrent(epoch)) { - return - } - // Ack 404 (command_not_found) must not mark the bridge unavailable. - await this.client.ackCommand({ commandId: command.commandId, status: "acked" }) - } catch (error) { - if (!this.isLifecycleCurrent(epoch)) { - return - } - await this.client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "execution_failed", - message: error instanceof Error ? error.message : String(error), - }) - } + await executeRemoteCommand({ + command, + epoch, + client: this.client, + host: { + isLifecycleCurrent: (value) => this.isLifecycleCurrent(value), + isEligible: () => this.evaluateEligible(), + sendMessage: (text, images) => this.api.sendMessage(text, images), + handleModeSwitch: (mode) => this.provider.handleModeSwitch(mode as never), + getCurrentTask: () => this.provider.getCurrentTask() as never, + cancelCurrentTask: () => this.api.cancelCurrentTask(), + resumeTask: (taskId) => this.api.resumeTask(taskId), + startNewTask: (payload) => this.api.startNewTask(payload), + }, + }) } } diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 9817efc914..acef3eb7e0 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -73,19 +73,24 @@ const TaskHeader = ({ const contextWindow = model?.contextWindow || 1 const isZooGateway = apiConfiguration?.apiProvider === providerIdentifiers.zooGateway - const canOfferRemoteSteer = Boolean(zooCodeIsAuthenticated && isZooGateway && currentTaskItem) + const canShowRemoteSteer = Boolean(zooCodeIsAuthenticated && currentTaskItem) - const enableRemoteSteer = () => { + const openRemoteDashboard = () => { + const base = (zooCodeBaseUrl || "https://www.zoocode.dev").replace(/\/$/, "") + vscode.postMessage({ type: "openExternal", url: `${base}/dashboard/agents/ide` }) + } + + const openProviderSettings = () => { + vscode.postMessage({ type: "switchTab", tab: "settings" }) + } + + const enableRemoteSteerAndOpenDashboard = () => { vscode.postMessage({ type: "updateVSCodeSetting", setting: "zoo-code.remoteControl.enabled", bool: true, }) - } - - const openRemoteDashboard = () => { - const base = (zooCodeBaseUrl || "https://www.zoocode.dev").replace(/\/$/, "") - vscode.postMessage({ type: "openExternal", url: `${base}/dashboard/agents/ide` }) + openRemoteDashboard() } // Calculate maxTokens (reserved for output) once for reuse in percentage and tooltip @@ -159,7 +164,7 @@ const TaskHeader = ({
)} - {canOfferRemoteSteer && remoteControlEnabled && ( + {canShowRemoteSteer && isZooGateway && remoteControlEnabled && (
e.stopPropagation()} @@ -173,7 +178,7 @@ const TaskHeader = ({
)} - {canOfferRemoteSteer && !remoteControlEnabled && ( + {canShowRemoteSteer && isZooGateway && !remoteControlEnabled && (
e.stopPropagation()} @@ -182,11 +187,27 @@ const TaskHeader = ({
)} + {canShowRemoteSteer && !isZooGateway && ( +
e.stopPropagation()} + data-testid="remote-steer-needs-gateway"> + + {t("chat:task.steerRemotelyNeedsGateway")} + + +
+ )}
{ }) it("shows an immediate manual control to enable remote steer, including on completed tasks", () => { + mockExtensionState.zooCodeBaseUrl = "https://www.zoocode.dev" renderTaskHeader() const offer = screen.getByTestId("remote-steer-offer") expect(offer).toHaveTextContent("chat:task.steerRemotelyHint") @@ -384,6 +385,10 @@ describe("TaskHeader", () => { setting: "zoo-code.remoteControl.enabled", bool: true, }) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openExternal", + url: "https://www.zoocode.dev/dashboard/agents/ide", + }) }) it("shows the active remote steer strip with a dashboard link", () => { @@ -398,5 +403,20 @@ describe("TaskHeader", () => { url: "https://www.zoocode.dev/dashboard/agents/ide", }) }) + + it("prompts to switch to Zoo Gateway when the active profile is not eligible", () => { + mockExtensionState.apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-api-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings + renderTaskHeader() + + expect(screen.getByTestId("remote-steer-needs-gateway")).toHaveTextContent( + "chat:task.steerRemotelyNeedsGateway", + ) + fireEvent.click(screen.getByRole("button", { name: "chat:task.steerRemotelyOpenSettings" })) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "switchTab", tab: "settings" }) + }) }) }) diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 52404d952c..e5aa3c3753 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -22,6 +22,8 @@ "goToSubtask": "Go to subtask", "steerRemotely": "Steer this task remotely", "steerRemotelyHint": "Mirror this task to the Zoo dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", "openRemoteDashboard": "Open dashboard" }, From 968abc39b516b4c3256ad0974d1d7a68dbe1eed6 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:02:04 -0600 Subject: [PATCH 17/36] fix: align startNewTask host callback return type Discard the task-id return so the remote command host matches Promise. --- src/services/zoo-remote-control/ZooRemoteControlBridge.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index 3bd536e765..83fdacf7c7 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -631,7 +631,9 @@ export class ZooRemoteControlBridge implements vscode.Disposable { getCurrentTask: () => this.provider.getCurrentTask() as never, cancelCurrentTask: () => this.api.cancelCurrentTask(), resumeTask: (taskId) => this.api.resumeTask(taskId), - startNewTask: (payload) => this.api.startNewTask(payload), + startNewTask: async (payload) => { + await this.api.startNewTask(payload) + }, }, }) } From 32ed5420eff895be04f2986314d105bfbac502a5 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:20:48 -0600 Subject: [PATCH 18/36] fix: address CodeRabbit remote-control review findings Harden command acks, payload size limits, deactivate shutdown, workspace setting writes, and bridge availability UI. Fill chat locale keys for translation CI and keep Gateway messaging only on the ineligible-profile banner. --- .../__tests__/executeRemoteCommand.spec.ts | 192 ++++++++++++++++-- .../__tests__/projectMessage.spec.ts | 19 ++ .../src/zoo-remote-control/availability.ts | 10 + .../executeRemoteCommand.ts | 152 +++++++------- packages/core/src/zoo-remote-control/index.ts | 1 + .../src/zoo-remote-control/projectMessage.ts | 37 +++- packages/types/src/vscode-extension-host.ts | 2 + src/core/task/Task.ts | 9 + src/core/webview/ClineProvider.ts | 2 + src/core/webview/webviewMessageHandler.ts | 11 +- src/extension.ts | 26 ++- .../ZooRemoteControlBridge.ts | 74 ++++++- .../__tests__/ZooRemoteControlBridge.spec.ts | 17 +- webview-ui/src/components/chat/TaskHeader.tsx | 26 ++- .../chat/__tests__/TaskHeader.spec.tsx | 18 ++ webview-ui/src/i18n/locales/ca/chat.json | 9 +- webview-ui/src/i18n/locales/de/chat.json | 9 +- webview-ui/src/i18n/locales/en/chat.json | 3 +- webview-ui/src/i18n/locales/es/chat.json | 9 +- webview-ui/src/i18n/locales/fr/chat.json | 9 +- webview-ui/src/i18n/locales/hi/chat.json | 9 +- webview-ui/src/i18n/locales/id/chat.json | 9 +- webview-ui/src/i18n/locales/it/chat.json | 9 +- webview-ui/src/i18n/locales/ja/chat.json | 9 +- webview-ui/src/i18n/locales/ko/chat.json | 9 +- webview-ui/src/i18n/locales/nl/chat.json | 9 +- webview-ui/src/i18n/locales/pl/chat.json | 9 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 9 +- webview-ui/src/i18n/locales/ru/chat.json | 9 +- webview-ui/src/i18n/locales/tr/chat.json | 9 +- webview-ui/src/i18n/locales/vi/chat.json | 9 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 9 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 9 +- 33 files changed, 613 insertions(+), 139 deletions(-) create mode 100644 packages/core/src/zoo-remote-control/availability.ts diff --git a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts index 2c8a1a8770..9db47f90b4 100644 --- a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts @@ -1,6 +1,21 @@ import { describe, expect, it, vi } from "vitest" import { executeRemoteCommand } from "../executeRemoteCommand.js" +import type { ZooRemoteCommandHost } from "../executeRemoteCommand.js" + +function makeHost(overrides: Partial = {}): ZooRemoteCommandHost { + return { + isLifecycleCurrent: () => true, + isEligible: () => true, + sendMessage: vi.fn().mockResolvedValue(undefined), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + getCurrentTask: () => undefined, + cancelCurrentTask: vi.fn().mockResolvedValue(undefined), + resumeTask: vi.fn().mockResolvedValue(undefined), + startNewTask: vi.fn().mockResolvedValue(undefined), + ...overrides, + } +} describe("executeRemoteCommand", () => { it("acks and sends a message when eligible and lifecycle is current", async () => { @@ -11,22 +26,28 @@ describe("executeRemoteCommand", () => { command: { commandId: "c1", type: "message", payload: { text: "hi" } }, epoch: 1, client: { ackCommand }, - host: { - isLifecycleCurrent: () => true, - isEligible: () => true, - sendMessage, - handleModeSwitch: vi.fn(), - getCurrentTask: () => undefined, - cancelCurrentTask: vi.fn(), - resumeTask: vi.fn(), - startNewTask: vi.fn(), - }, + host: makeHost({ sendMessage }), }) expect(sendMessage).toHaveBeenCalledWith("hi", undefined) expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) }) + it("switches mode after sending a message when payload.mode is set", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const handleModeSwitch = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", payload: { text: "hi", mode: "code" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ handleModeSwitch }), + }) + + expect(handleModeSwitch).toHaveBeenCalledWith("code") + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + it("skips host actions when lifecycle is stale", async () => { const ackCommand = vi.fn() const sendMessage = vi.fn() @@ -35,19 +56,154 @@ describe("executeRemoteCommand", () => { command: { commandId: "c1", type: "message", payload: { text: "hi" } }, epoch: 1, client: { ackCommand }, - host: { + host: makeHost({ isLifecycleCurrent: () => false, - isEligible: () => true, sendMessage, - handleModeSwitch: vi.fn(), - getCurrentTask: () => undefined, - cancelCurrentTask: vi.fn(), - resumeTask: vi.fn(), - startNewTask: vi.fn(), - }, + }), }) expect(sendMessage).not.toHaveBeenCalled() expect(ackCommand).not.toHaveBeenCalled() }) + + it("acks not_eligible when the host is ineligible", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", payload: { text: "hi" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ isEligible: () => false }), + }) + + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "not_eligible", + }) + }) + + it("acks no_current_task when approve_ask has no task", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "approve_ask" }, + epoch: 1, + client: { ackCommand }, + host: makeHost(), + }) + + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "no_current_task", + }) + }) + + it("acks no_pending_ask when approve_ask has no pending ask", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const approveAsk = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "approve_ask" }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => ({ + taskId: "t1", + hasPendingAsk: () => false, + approveAsk, + denyAsk: vi.fn(), + }), + }), + }) + + expect(approveAsk).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "no_pending_ask", + }) + }) + + it("acks task_mismatch when command.taskId does not match the live task", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const cancelCurrentTask = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "cancel_task", taskId: "other" }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => ({ + taskId: "t1", + hasPendingAsk: () => false, + approveAsk: vi.fn(), + denyAsk: vi.fn(), + }), + cancelCurrentTask, + }), + }) + + expect(cancelCurrentTask).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "task_mismatch", + }) + }) + + it("acks missing_task_id for resume_task without a task id", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "resume_task" }, + epoch: 1, + client: { ackCommand }, + host: makeHost(), + }) + + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "missing_task_id", + }) + }) + + it("acks missing_mode for switch_mode without a mode", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "switch_mode", payload: {} }, + epoch: 1, + client: { ackCommand }, + host: makeHost(), + }) + + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "missing_mode", + }) + }) + + it("acks execution_failed when the host throws", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", payload: { text: "hi" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + sendMessage: vi.fn().mockRejectedValue(new Error("boom")), + }), + }) + + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "execution_failed", + message: "boom", + }) + }) }) diff --git a/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts b/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts index f4e54c7a26..6ac8aa623e 100644 --- a/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts @@ -5,6 +5,7 @@ import { projectClineMessagesForSnapshot, REMOTE_MESSAGE_IMAGE_MAX_CHARS, REMOTE_MESSAGE_IMAGE_MAX_COUNT, + REMOTE_MESSAGE_JSON_MAX_CHARS, REMOTE_MESSAGE_TEXT_MAX_CHARS, REMOTE_SNAPSHOT_MAX_MESSAGES, } from "../projectMessage.js" @@ -92,6 +93,24 @@ describe("projectClineMessageForRemote", () => { ).toContain("[truncated") }) + it("enforces an aggregate serialized character budget across many large fields", () => { + const chunk = "z".repeat(20_000) + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify({ + a: chunk, + b: chunk, + c: chunk, + d: chunk, + e: chunk, + f: chunk, + }), + }) + expect(shaped.text!.length).toBeLessThanOrEqual(REMOTE_MESSAGE_JSON_MAX_CHARS + 80) + }) + it("recursively truncates nested tool JSON strings beyond top-level keys", () => { const huge = "n".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 80) const shaped = projectClineMessageForRemote({ diff --git a/packages/core/src/zoo-remote-control/availability.ts b/packages/core/src/zoo-remote-control/availability.ts new file mode 100644 index 0000000000..be053ec4d8 --- /dev/null +++ b/packages/core/src/zoo-remote-control/availability.ts @@ -0,0 +1,10 @@ +let remoteControlAvailable = false + +/** Extension bridge publishes reachability; webview state reads it. */ +export function setZooRemoteControlAvailable(available: boolean): void { + remoteControlAvailable = available +} + +export function getZooRemoteControlAvailable(): boolean { + return remoteControlAvailable +} diff --git a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts index 3cd3a61ff4..e16cd9747e 100644 --- a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts +++ b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts @@ -1,22 +1,62 @@ import type { ZooRemoteCommand } from "./types.js" import type { ZooRemoteControlClient } from "./ZooRemoteControlClient.js" +export type ZooRemoteCommandHostTask = { + taskId: string + hasPendingAsk: () => boolean + approveAsk: (payload?: { text?: string; images?: string[] }) => void + denyAsk: (payload?: { text?: string; images?: string[] }) => void +} + export type ZooRemoteCommandHost = { isLifecycleCurrent: (epoch: number) => boolean isEligible: () => boolean sendMessage: (text?: string, images?: string[]) => Promise handleModeSwitch: (mode: string) => Promise - getCurrentTask: () => - | { - approveAsk: (payload?: { text?: string; images?: string[] }) => void - denyAsk: (payload?: { text?: string; images?: string[] }) => void - } - | undefined + getCurrentTask: () => ZooRemoteCommandHostTask | undefined cancelCurrentTask: () => Promise resumeTask: (taskId: string) => Promise startNewTask: (payload: { text?: string; images?: string[] }) => Promise } +async function failCommand( + client: Pick, + host: ZooRemoteCommandHost, + epoch: number, + commandId: string, + errorCode: string, + message?: string, +): Promise { + if (!host.isLifecycleCurrent(epoch)) { + return + } + await client.ackCommand({ + commandId, + status: "failed", + errorCode, + ...(message ? { message } : {}), + }) +} + +/** When a command targets a task id, require it to match the live task. */ +async function ensureTaskTarget(options: { + command: ZooRemoteCommand + epoch: number + client: Pick + host: ZooRemoteCommandHost +}): Promise { + const { command, epoch, client, host } = options + if (!command.taskId) { + return true + } + const currentId = host.getCurrentTask()?.taskId + if (currentId === command.taskId) { + return true + } + await failCommand(client, host, epoch, command.commandId, "task_mismatch") + return false +} + /** Apply one remote command through host callbacks, respecting lifecycle epoch checks. */ export async function executeRemoteCommand(options: { command: ZooRemoteCommand @@ -31,14 +71,7 @@ export async function executeRemoteCommand(options: { } if (!host.isEligible()) { - if (!host.isLifecycleCurrent(epoch)) { - return - } - await client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "not_eligible", - }) + await failCommand(client, host, epoch, command.commandId, "not_eligible") return } @@ -46,6 +79,9 @@ export async function executeRemoteCommand(options: { switch (command.type) { case "message": case "answer_followup": + if (!(await ensureTaskTarget({ command, epoch, client, host }))) { + return + } if (!host.isLifecycleCurrent(epoch)) { return } @@ -57,45 +93,34 @@ export async function executeRemoteCommand(options: { await host.handleModeSwitch(command.payload.mode) } break - case "approve_ask": { - const task = host.getCurrentTask() - if (!task) { - if (!host.isLifecycleCurrent(epoch)) { - return - } - await client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "no_current_task", - }) - return - } - if (!host.isLifecycleCurrent(epoch)) { + case "approve_ask": + case "deny_ask": { + if (!(await ensureTaskTarget({ command, epoch, client, host }))) { return } - task.approveAsk({ text: command.payload?.text, images: command.payload?.images }) - break - } - case "deny_ask": { const task = host.getCurrentTask() if (!task) { - if (!host.isLifecycleCurrent(epoch)) { - return - } - await client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "no_current_task", - }) + await failCommand(client, host, epoch, command.commandId, "no_current_task") + return + } + if (!task.hasPendingAsk()) { + await failCommand(client, host, epoch, command.commandId, "no_pending_ask") return } if (!host.isLifecycleCurrent(epoch)) { return } - task.denyAsk({ text: command.payload?.text, images: command.payload?.images }) + if (command.type === "approve_ask") { + task.approveAsk({ text: command.payload?.text, images: command.payload?.images }) + } else { + task.denyAsk({ text: command.payload?.text, images: command.payload?.images }) + } break } case "cancel_task": + if (!(await ensureTaskTarget({ command, epoch, client, host }))) { + return + } if (!host.isLifecycleCurrent(epoch)) { return } @@ -103,14 +128,7 @@ export async function executeRemoteCommand(options: { break case "resume_task": if (!command.taskId) { - if (!host.isLifecycleCurrent(epoch)) { - return - } - await client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "missing_task_id", - }) + await failCommand(client, host, epoch, command.commandId, "missing_task_id") return } if (!host.isLifecycleCurrent(epoch)) { @@ -130,14 +148,7 @@ export async function executeRemoteCommand(options: { case "switch_mode": { const mode = command.payload?.mode if (!mode) { - if (!host.isLifecycleCurrent(epoch)) { - return - } - await client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "missing_mode", - }) + await failCommand(client, host, epoch, command.commandId, "missing_mode") return } if (!host.isLifecycleCurrent(epoch)) { @@ -148,15 +159,7 @@ export async function executeRemoteCommand(options: { } default: { const _exhaustive: never = command.type - if (!host.isLifecycleCurrent(epoch)) { - return - } - await client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "unknown_command", - message: String(_exhaustive), - }) + await failCommand(client, host, epoch, command.commandId, "unknown_command", String(_exhaustive)) return } } @@ -167,14 +170,13 @@ export async function executeRemoteCommand(options: { // Ack 404 (command_not_found) must not mark the bridge unavailable. await client.ackCommand({ commandId: command.commandId, status: "acked" }) } catch (error) { - if (!host.isLifecycleCurrent(epoch)) { - return - } - await client.ackCommand({ - commandId: command.commandId, - status: "failed", - errorCode: "execution_failed", - message: error instanceof Error ? error.message : String(error), - }) + await failCommand( + client, + host, + epoch, + command.commandId, + "execution_failed", + error instanceof Error ? error.message : String(error), + ) } } diff --git a/packages/core/src/zoo-remote-control/index.ts b/packages/core/src/zoo-remote-control/index.ts index c12bcad4cd..0c773fd7dc 100644 --- a/packages/core/src/zoo-remote-control/index.ts +++ b/packages/core/src/zoo-remote-control/index.ts @@ -1,4 +1,5 @@ export * from "./eligibility.js" +export * from "./availability.js" export * from "./executeRemoteCommand.js" export * from "./projectMessage.js" export * from "./types.js" diff --git a/packages/core/src/zoo-remote-control/projectMessage.ts b/packages/core/src/zoo-remote-control/projectMessage.ts index 63d29c885b..2166a0af8a 100644 --- a/packages/core/src/zoo-remote-control/projectMessage.ts +++ b/packages/core/src/zoo-remote-control/projectMessage.ts @@ -7,27 +7,36 @@ export const REMOTE_MESSAGE_IMAGE_MAX_CHARS = 8_000 export const REMOTE_SNAPSHOT_MAX_MESSAGES = 80 /** Cap nested arrays (e.g. batchDiffs) so snapshots stay bounded. */ export const REMOTE_MESSAGE_ARRAY_MAX_ITEMS = 20 +/** Aggregate serialized-character budget for a single tool JSON payload. */ +export const REMOTE_MESSAGE_JSON_MAX_CHARS = 96_000 function truncateString(value: string, max: number): string { + if (max <= 0) { + return "" + } if (value.length <= max) { return value } - return `${value.slice(0, max)}\n…[truncated ${value.length - max} chars]` + // Keep the returned length within `max` (marker fits inside the budget). + const marker = `\n…[truncated ${value.length - max} chars]` + const head = Math.max(0, max - marker.length) + return `${value.slice(0, head)}${marker}` } -function boundJsonValue(value: unknown): unknown { +/** Bound nested JSON while preserving keys/array slots; `stringCap` limits each string. */ +function boundJsonValue(value: unknown, stringCap: number): unknown { if (typeof value === "string") { - return truncateString(value, REMOTE_MESSAGE_TEXT_MAX_CHARS) + return truncateString(value, stringCap) } if (Array.isArray(value)) { - return value.slice(0, REMOTE_MESSAGE_ARRAY_MAX_ITEMS).map((entry) => boundJsonValue(entry)) + return value.slice(0, REMOTE_MESSAGE_ARRAY_MAX_ITEMS).map((entry) => boundJsonValue(entry, stringCap)) } if (value && typeof value === "object") { const record: Record = {} for (const [key, child] of Object.entries(value as Record)) { - record[key] = boundJsonValue(child) + record[key] = boundJsonValue(child, stringCap) } return record } @@ -42,7 +51,23 @@ function shapeToolText(text: string): string { return truncateString(text, REMOTE_MESSAGE_TEXT_MAX_CHARS) } - return JSON.stringify(boundJsonValue(parsed)) + let stringCap = REMOTE_MESSAGE_TEXT_MAX_CHARS + let bounded = boundJsonValue(parsed, stringCap) + let serialized = JSON.stringify(bounded) + + // If the payload is still too large, shrink every string together so structure + // (keys, array slots, small metadata) stays intact under the aggregate budget. + while (serialized.length > REMOTE_MESSAGE_JSON_MAX_CHARS && stringCap > 512) { + stringCap = Math.floor(stringCap * 0.5) + bounded = boundJsonValue(parsed, stringCap) + serialized = JSON.stringify(bounded) + } + + if (serialized.length > REMOTE_MESSAGE_JSON_MAX_CHARS) { + return truncateString(serialized, REMOTE_MESSAGE_JSON_MAX_CHARS) + } + + return serialized } catch { return truncateString(text, REMOTE_MESSAGE_TEXT_MAX_CHARS) } diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f6f8162b9b..9c7975cf41 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -422,6 +422,8 @@ export type ExtensionState = Pick< zooCodeBaseUrl?: string /** VS Code setting zoo-code.remoteControl.enabled — dashboard remote steer opt-in. */ remoteControlEnabled?: boolean + /** True when the Zoo remote-control bridge is active and the remote endpoint is reachable. */ + remoteControlAvailable?: boolean deviceName?: string debug?: boolean diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b84a449c1d..cc4535efd9 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2456,6 +2456,15 @@ export class Task extends EventEmitter implements TaskLike { this.handleWebviewAskResponse("noButtonClicked", text, images) } + /** True while `ask()` is blocked waiting for a webview/remote response. */ + public hasPendingAsk(): boolean { + if (this.askResponse !== undefined || this.abort) { + return false + } + const last = this.clineMessages.at(-1) + return Boolean(last && last.type === "ask" && !last.partial && last.ts === this.lastMessageTs) + } + public supersedePendingAsk(): void { this.lastMessageTs = Date.now() } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6900bdae5f..31268fc32c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -89,6 +89,7 @@ import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" +import { getZooRemoteControlAvailable } from "@roo-code/core" import { MarketplaceManager } from "../../services/marketplace" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" @@ -2909,6 +2910,7 @@ export class ClineProvider remoteControlEnabled: vscode.workspace .getConfiguration(Package.name) .get("remoteControl.enabled", false), + remoteControlAvailable: getZooRemoteControlAvailable(), platform: process.platform, arch: process.arch, debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4d87a6f9cf..6fd34dbbb1 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1953,7 +1953,16 @@ export const webviewMessageHandler = async ( if (setting !== undefined && value !== undefined) { if (ALLOWED_VSCODE_SETTINGS.has(setting)) { - await vscode.workspace.getConfiguration().update(setting, value, true) + const config = vscode.workspace.getConfiguration() + const inspected = config.inspect(setting) + // Prefer the winning workspace scope so enabling is not no-oped by workspace `false`. + const target = + inspected?.workspaceFolderValue !== undefined + ? vscode.ConfigurationTarget.WorkspaceFolder + : inspected?.workspaceValue !== undefined || vscode.workspace.workspaceFolders?.length + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global + await config.update(setting, value, target) if (setting === "zoo-code.remoteControl.enabled") { await provider.postStateToWebview() } diff --git a/src/extension.ts b/src/extension.ts index 3d2192427b..ce218c290d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -391,22 +391,32 @@ export async function activate(context: vscode.ExtensionContext) { const providerFactory = new ClineProviderFactory(context, outputChannel, webviewFocusTracker) const api = new API(outputChannel, provider, providerFactory, socketPath, enableLogging) - context.subscriptions.push( - new ZooRemoteControlBridge({ - api, - provider, - context, - log: (message) => outputChannel.appendLine(message), - }), - ) + zooRemoteControlBridge = new ZooRemoteControlBridge({ + api, + provider, + context, + log: (message) => outputChannel.appendLine(message), + }) + context.subscriptions.push(zooRemoteControlBridge) return api } +let zooRemoteControlBridge: ZooRemoteControlBridge | undefined + // This method is called when your extension is deactivated. export async function deactivate() { outputChannel.appendLine(`${Package.name} extension deactivated`) + try { + await zooRemoteControlBridge?.shutdown() + } catch (error) { + outputChannel.appendLine( + `Failed to shut down Zoo remote control: ${error instanceof Error ? error.message : String(error)}`, + ) + } + zooRemoteControlBridge = undefined + if (cloudService && CloudService.hasInstance()) { try { if (settingsUpdatedHandler) { diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index 83fdacf7c7..b90ba4d7c2 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -5,6 +5,7 @@ import { isZooRemoteControlEligible, projectClineMessageForRemote, projectClineMessagesForSnapshot, + setZooRemoteControlAvailable, ZooRemoteControlClient, type ZooRemoteCommand, type ZooRemoteEvent, @@ -19,6 +20,7 @@ import { import type { ClineProvider } from "../../core/webview/ClineProvider" import { Package } from "../../shared/package" +import type { Mode } from "../../shared/modes" import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../zoo-code-auth" const PRESENCE_INTERVAL_MS = 15_000 @@ -52,6 +54,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { private disposed = false private active = false private serverAvailable = true + private lastPostedAvailability: boolean | undefined private unavailableUntil = 0 private eventSeq: number private pendingEvents: ZooRemoteEvent[] = [] @@ -148,10 +151,28 @@ export class ZooRemoteControlBridge implements vscode.Disposable { void this.syncActiveState() } + /** Awaitable teardown so offline presence can finish before extension deactivate returns. */ + public async shutdown(): Promise { + if (this.disposed) { + return + } + this.lifecycleEpoch += 1 + await this.deactivate({ notifyOffline: true }) + this.releaseResources() + } + public dispose(): void { - this.disposed = true + if (this.disposed) { + return + } this.lifecycleEpoch += 1 void this.deactivate({ notifyOffline: true }) + this.releaseResources() + } + + private releaseResources(): void { + this.disposed = true + setZooRemoteControlAvailable(false) if (typeof this.provider.off === "function") { this.provider.off(RooCodeEventName.ProviderProfileChanged, this.onProviderProfileChanged) } @@ -279,8 +300,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } this.active = true - this.serverAvailable = true - this.unavailableUntil = 0 + this.setServerAvailable(true) this.bindApiListeners(true) this.presenceTimer = setInterval(() => { void this.publishPresence(true) @@ -310,6 +330,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.bindApiListeners(false) this.pendingEvents = [] this.pendingPartials.clear() + this.publishAvailabilityToWebview() if (notifyOffline) { // Always try offline presence while a session token remains (even if provider changed). @@ -425,11 +446,35 @@ export class ZooRemoteControlBridge implements vscode.Disposable { private markUnavailable(): void { this.serverAvailable = false this.unavailableUntil = this.now() + UNAVAILABLE_BACKOFF_MS + this.publishAvailabilityToWebview() } private markNotEligibleBackoff(): void { this.serverAvailable = false this.unavailableUntil = this.now() + NOT_ELIGIBLE_BACKOFF_MS + this.publishAvailabilityToWebview() + } + + private setServerAvailable(available: boolean): void { + this.serverAvailable = available + if (available) { + this.unavailableUntil = 0 + } + this.publishAvailabilityToWebview() + } + + private isServerAvailable(): boolean { + return this.serverAvailable || this.now() >= this.unavailableUntil + } + + private publishAvailabilityToWebview(): void { + const available = this.active && this.isServerAvailable() + if (this.lastPostedAvailability === available) { + return + } + this.lastPostedAvailability = available + setZooRemoteControlAvailable(available) + void this.provider.postStateToWebview() } private canCallServer(): boolean { @@ -492,7 +537,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } if (result.ok) { - this.serverAvailable = true + this.setServerAvailable(true) } } @@ -531,7 +576,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } if (result.ok) { - this.serverAvailable = true + this.setServerAvailable(true) } } @@ -569,7 +614,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - this.serverAvailable = true + this.setServerAvailable(true) } private async pollCommands(): Promise { @@ -605,7 +650,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - this.serverAvailable = true + this.setServerAvailable(true) for (const command of result.body.commands) { if (!this.isLifecycleCurrent(epoch)) { @@ -627,8 +672,19 @@ export class ZooRemoteControlBridge implements vscode.Disposable { isLifecycleCurrent: (value) => this.isLifecycleCurrent(value), isEligible: () => this.evaluateEligible(), sendMessage: (text, images) => this.api.sendMessage(text, images), - handleModeSwitch: (mode) => this.provider.handleModeSwitch(mode as never), - getCurrentTask: () => this.provider.getCurrentTask() as never, + handleModeSwitch: (mode) => this.provider.handleModeSwitch(mode as Mode), + getCurrentTask: () => { + const task = this.provider.getCurrentTask() + if (!task) { + return undefined + } + return { + taskId: task.taskId, + hasPendingAsk: () => task.hasPendingAsk(), + approveAsk: (payload) => task.approveAsk(payload), + denyAsk: (payload) => task.denyAsk(payload), + } + }, cancelCurrentTask: () => this.api.cancelCurrentTask(), resumeTask: (taskId) => this.api.resumeTask(taskId), startNewTask: async (payload) => { diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index 7298de191e..cd84cc8216 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -78,6 +78,7 @@ describe("ZooRemoteControlBridge", () => { }, getCurrentTask: vi.fn(() => undefined), handleModeSwitch: vi.fn().mockResolvedValue(undefined), + postStateToWebview: vi.fn().mockResolvedValue(undefined), }) client = { @@ -308,7 +309,12 @@ describe("ZooRemoteControlBridge", () => { } it("approves and denies asks on the current task", async () => { - const task = { approveAsk: vi.fn(), denyAsk: vi.fn() } + const task = { + taskId: "task-1", + hasPendingAsk: vi.fn(() => true), + approveAsk: vi.fn(), + denyAsk: vi.fn(), + } provider.getCurrentTask.mockReturnValue(task) client.getCommands.mockResolvedValueOnce({ ok: true, @@ -692,6 +698,15 @@ describe("ZooRemoteControlBridge", () => { api.emit(RooCodeEventName.TaskIdle, "task-1") await vi.advanceTimersByTimeAsync(100) expect(client.postEvents).toHaveBeenCalled() + + client.postEvents.mockClear() + client.postEvents.mockResolvedValue({ ok: true, status: 200 }) + // not-eligible backoff is 30s; flush only retries after canCallServer() opens again. + await vi.advanceTimersByTimeAsync(30_000 + 100) + expect(client.postEvents).toHaveBeenCalledWith( + "instance-1", + expect.arrayContaining([expect.objectContaining({ type: "task_idle", taskId: "task-1" })]), + ) bridge.dispose() }) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index acef3eb7e0..7735cec592 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -63,8 +63,14 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem, zooCodeIsAuthenticated, zooCodeBaseUrl, remoteControlEnabled } = - useExtensionState() + const { + apiConfiguration, + currentTaskItem, + zooCodeIsAuthenticated, + zooCodeBaseUrl, + remoteControlEnabled, + remoteControlAvailable, + } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) @@ -164,7 +170,7 @@ const TaskHeader = ({
)} - {canShowRemoteSteer && isZooGateway && remoteControlEnabled && ( + {canShowRemoteSteer && isZooGateway && remoteControlEnabled && remoteControlAvailable && (
e.stopPropagation()} @@ -178,6 +184,20 @@ const TaskHeader = ({
)} + {canShowRemoteSteer && isZooGateway && remoteControlEnabled && !remoteControlAvailable && ( +
e.stopPropagation()} + data-testid="remote-steer-unavailable"> + {t("chat:task.steerRemotelyUnavailable")} + +
+ )} {canShowRemoteSteer && isZooGateway && !remoteControlEnabled && (
{ } as ProviderSettings mockExtensionState.zooCodeIsAuthenticated = true mockExtensionState.remoteControlEnabled = false + mockExtensionState.remoteControlAvailable = false + mockExtensionState.zooCodeBaseUrl = undefined mockExtensionState.clineMessages = [{ type: "say", say: "completion_result", ts: 1, text: "done" }] }) @@ -371,6 +375,8 @@ describe("TaskHeader", () => { } as ProviderSettings mockExtensionState.zooCodeIsAuthenticated = false mockExtensionState.remoteControlEnabled = false + mockExtensionState.remoteControlAvailable = false + mockExtensionState.zooCodeBaseUrl = undefined mockExtensionState.clineMessages = [] }) @@ -393,6 +399,7 @@ describe("TaskHeader", () => { it("shows the active remote steer strip with a dashboard link", () => { mockExtensionState.remoteControlEnabled = true + mockExtensionState.remoteControlAvailable = true mockExtensionState.zooCodeBaseUrl = "https://www.zoocode.dev" renderTaskHeader() @@ -404,6 +411,17 @@ describe("TaskHeader", () => { }) }) + it("shows unavailable state when remote control is enabled but the bridge is down", () => { + mockExtensionState.remoteControlEnabled = true + mockExtensionState.remoteControlAvailable = false + renderTaskHeader() + + expect(screen.getByTestId("remote-steer-unavailable")).toHaveTextContent( + "chat:task.steerRemotelyUnavailable", + ) + expect(screen.queryByTestId("remote-steer-active")).not.toBeInTheDocument() + }) + it("prompts to switch to Zoo Gateway when the active profile is not eligible", () => { mockExtensionState.apiConfiguration = { apiProvider: providerIdentifiers.anthropic, diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 7779a0a5cb..44a97d33cf 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "Obrir historial d'UI", "backToParentTask": "Tasca principal", "waitingOnSubtask": "Esperant subtasca", - "goToSubtask": "Anar a la subtasca" + "goToSubtask": "Anar a la subtasca", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Desfixar", "pin": "Fixar", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 45f9d34c3b..2d22e40fb3 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "UI-Verlauf öffnen", "backToParentTask": "Übergeordnete Aufgabe", "waitingOnSubtask": "Wartet auf Unteraufgabe", - "goToSubtask": "Zur Unteraufgabe" + "goToSubtask": "Zur Unteraufgabe", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Lösen von oben", "pin": "Anheften", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index e5aa3c3753..87a4d49d22 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -21,10 +21,11 @@ "waitingOnSubtask": "Waiting on subtask", "goToSubtask": "Go to subtask", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Mirror this task to the Zoo dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, "unpin": "Unpin", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 9f2727493f..272ff8d61b 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "Abrir historial de UI", "backToParentTask": "Tarea principal", "waitingOnSubtask": "Esperando subtarea", - "goToSubtask": "Ir a la subtarea" + "goToSubtask": "Ir a la subtarea", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Desfijar", "pin": "Fijar", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 2b4cf8c557..1466234635 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "Ouvrir l'historique de l'UI", "backToParentTask": "Tâche parente", "waitingOnSubtask": "En attente de sous-tâche", - "goToSubtask": "Aller à la sous-tâche" + "goToSubtask": "Aller à la sous-tâche", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Désépingler", "pin": "Épingler", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index ebbd679434..9e08ab5e31 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "UI इतिहास खोलें", "backToParentTask": "मूल कार्य", "waitingOnSubtask": "उपकार्य की प्रतीक्षा", - "goToSubtask": "उपकार्य पर जाएं" + "goToSubtask": "उपकार्य पर जाएं", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "पिन करें", "pin": "अवपिन करें", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index d097894162..1defffbb74 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "Buka Riwayat UI", "backToParentTask": "Tugas Induk", "waitingOnSubtask": "Menunggu subtugas", - "goToSubtask": "Pergi ke subtugas" + "goToSubtask": "Pergi ke subtugas", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "history": { "title": "Riwayat" diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index e99c76e702..c28bb6b95c 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "Apri cronologia UI", "backToParentTask": "Attività principale", "waitingOnSubtask": "In attesa di sottoattività", - "goToSubtask": "Vai alla sottoattività" + "goToSubtask": "Vai alla sottoattività", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Rilascia", "pin": "Fissa", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 5ffd5c873c..5351e1b7ce 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "UI履歴を開く", "backToParentTask": "親タスク", "waitingOnSubtask": "サブタスク待ち", - "goToSubtask": "サブタスクへ" + "goToSubtask": "サブタスクへ", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "ピン留めを解除", "pin": "ピン留め", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 313d3b4722..bb5d039498 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "UI 기록 열기", "backToParentTask": "상위 작업", "waitingOnSubtask": "하위 작업 대기 중", - "goToSubtask": "하위 작업으로 이동" + "goToSubtask": "하위 작업으로 이동", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "고정 해제하기", "pin": "고정하기", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 1e091996c6..854a011f14 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "UI-geschiedenis openen", "backToParentTask": "Bovenliggende taak", "waitingOnSubtask": "Wacht op subtaak", - "goToSubtask": "Ga naar subtaak" + "goToSubtask": "Ga naar subtaak", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Losmaken", "pin": "Vastmaken", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 76ff83065b..38870d578e 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "Otwórz historię UI", "backToParentTask": "Zadanie nadrzędne", "waitingOnSubtask": "Oczekuje na podzadanie", - "goToSubtask": "Przejdź do podzadania" + "goToSubtask": "Przejdź do podzadania", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Odepnij", "pin": "Przypnij", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 5884e3424b..f83884c7fa 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "Abrir histórico da UI", "backToParentTask": "Tarefa pai", "waitingOnSubtask": "Aguardando subtarefa", - "goToSubtask": "Ir para subtarefa" + "goToSubtask": "Ir para subtarefa", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Desfixar", "pin": "Fixar", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 8de32b56fe..784a8dd702 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "Открыть историю UI", "backToParentTask": "Родительская задача", "waitingOnSubtask": "Ожидание подзадачи", - "goToSubtask": "Перейти к подзадаче" + "goToSubtask": "Перейти к подзадаче", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Открепить", "pin": "Закрепить", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 0cb4576ee0..8d4d6e6ea3 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "UI Geçmişini Aç", "backToParentTask": "Üst görev", "waitingOnSubtask": "Alt görev bekleniyor", - "goToSubtask": "Alt göreve git" + "goToSubtask": "Alt göreve git", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Sabitlemeyi iptal et", "pin": "Sabitle", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 5a3965a5c1..55f7981582 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "Mở lịch sử UI", "backToParentTask": "Nhiệm vụ cha", "waitingOnSubtask": "Đang chờ nhiệm vụ con", - "goToSubtask": "Đến nhiệm vụ con" + "goToSubtask": "Đến nhiệm vụ con", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "Bỏ ghim khỏi đầu", "pin": "Ghim lên đầu", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 982220c2ea..9817ff93f3 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "打开 UI 历史", "backToParentTask": "父任务", "waitingOnSubtask": "等待子任务", - "goToSubtask": "前往子任务" + "goToSubtask": "前往子任务", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "取消置顶", "pin": "置顶", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 65fbc49176..b046b6e90b 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -19,7 +19,14 @@ "openUiHistory": "開啟 UI 歷史紀錄", "backToParentTask": "上層工作", "waitingOnSubtask": "等待子任務", - "goToSubtask": "前往子任務" + "goToSubtask": "前往子任務", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard" }, "unpin": "取消釘選", "pin": "釘選", From 88ec4e177606f2678ef845c65a3b94006c416a40 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:48:30 -0600 Subject: [PATCH 19/36] fix: soften remote-steer copy and avoid workspace settings writes Use a comma in the active-steer banner and only update workspace-scoped remoteControl when that scope already defines the key, so enabling steer does not create .vscode/settings.json. --- packages/types/src/vscode-extension-host.ts | 2 +- src/core/webview/webviewMessageHandler.ts | 5 +++-- webview-ui/src/i18n/locales/ca/chat.json | 2 +- webview-ui/src/i18n/locales/de/chat.json | 2 +- webview-ui/src/i18n/locales/en/chat.json | 2 +- webview-ui/src/i18n/locales/es/chat.json | 2 +- webview-ui/src/i18n/locales/fr/chat.json | 2 +- webview-ui/src/i18n/locales/hi/chat.json | 2 +- webview-ui/src/i18n/locales/id/chat.json | 2 +- webview-ui/src/i18n/locales/it/chat.json | 2 +- webview-ui/src/i18n/locales/ja/chat.json | 2 +- webview-ui/src/i18n/locales/ko/chat.json | 2 +- webview-ui/src/i18n/locales/nl/chat.json | 2 +- webview-ui/src/i18n/locales/pl/chat.json | 2 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 2 +- webview-ui/src/i18n/locales/ru/chat.json | 2 +- webview-ui/src/i18n/locales/tr/chat.json | 2 +- webview-ui/src/i18n/locales/vi/chat.json | 2 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 2 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 2 +- 20 files changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 9c7975cf41..b67ff31f8e 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -420,7 +420,7 @@ export type ExtensionState = Pick< zooCodeUserEmail?: string zooCodeUserImage?: string zooCodeBaseUrl?: string - /** VS Code setting zoo-code.remoteControl.enabled — dashboard remote steer opt-in. */ + /** VS Code setting zoo-code.remoteControl.enabled: dashboard remote steer opt-in. */ remoteControlEnabled?: boolean /** True when the Zoo remote-control bridge is active and the remote endpoint is reachable. */ remoteControlAvailable?: boolean diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 6fd34dbbb1..6fa526bfb8 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1955,11 +1955,12 @@ export const webviewMessageHandler = async ( if (ALLOWED_VSCODE_SETTINGS.has(setting)) { const config = vscode.workspace.getConfiguration() const inspected = config.inspect(setting) - // Prefer the winning workspace scope so enabling is not no-oped by workspace `false`. + // Only touch workspace/folder when that scope already defines the key — otherwise + // write Global so enabling remote control does not create .vscode/settings.json. const target = inspected?.workspaceFolderValue !== undefined ? vscode.ConfigurationTarget.WorkspaceFolder - : inspected?.workspaceValue !== undefined || vscode.workspace.workspaceFolders?.length + : inspected?.workspaceValue !== undefined ? vscode.ConfigurationTarget.Workspace : vscode.ConfigurationTarget.Global await config.update(setting, value, target) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 44a97d33cf..23d646f807 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 2d22e40fb3..243db71ffa 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 87a4d49d22..d718835e76 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 272ff8d61b..9777835e95 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 1466234635..1d103a86d4 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 9e08ab5e31..4ec7393685 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 1defffbb74..66250359fd 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index c28bb6b95c..22f2231843 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 5351e1b7ce..24b26b326b 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index bb5d039498..946200a3cd 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 854a011f14..a7cdde5355 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 38870d578e..b8735d8884 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index f83884c7fa..f01e73394a 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 784a8dd702..86ab085a2d 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 8d4d6e6ea3..5c286e29ad 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 55f7981582..7e6c890188 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 9817ff93f3..9a7ed85f08 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index b046b6e90b..efbe91e27a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -24,7 +24,7 @@ "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on — control this task from the Zoo dashboard.", + "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", "openRemoteDashboard": "Open dashboard" }, From 8d6e9dbb4339175cf34779753bcc263c02178a74 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:52:49 -0600 Subject: [PATCH 20/36] test: cover remote-command success paths and Global remoteControl writes Raise codecov/patch coverage for executeRemoteCommand and the updateVSCodeSetting Global-vs-Workspace target selection. --- .../__tests__/executeRemoteCommand.spec.ts | 112 ++++++++++++++++++ .../__tests__/webviewMessageHandler.spec.ts | 45 +++++++ 2 files changed, 157 insertions(+) diff --git a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts index 9db47f90b4..cb8368f3ad 100644 --- a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts @@ -206,4 +206,116 @@ describe("executeRemoteCommand", () => { message: "boom", }) }) + + it("approves a pending ask and acks", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const approveAsk = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "approve_ask", payload: { text: "ok" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => ({ + taskId: "t1", + hasPendingAsk: () => true, + approveAsk, + denyAsk: vi.fn(), + }), + }), + }) + + expect(approveAsk).toHaveBeenCalledWith({ text: "ok", images: undefined }) + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("denies a pending ask and acks", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const denyAsk = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "deny_ask", payload: { text: "no" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => ({ + taskId: "t1", + hasPendingAsk: () => true, + approveAsk: vi.fn(), + denyAsk, + }), + }), + }) + + expect(denyAsk).toHaveBeenCalledWith({ text: "no", images: undefined }) + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("cancels the current task when taskId matches", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const cancelCurrentTask = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "cancel_task", taskId: "t1" }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => ({ + taskId: "t1", + hasPendingAsk: () => false, + approveAsk: vi.fn(), + denyAsk: vi.fn(), + }), + cancelCurrentTask, + }), + }) + + expect(cancelCurrentTask).toHaveBeenCalledOnce() + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("resumes a task by id", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const resumeTask = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "resume_task", taskId: "t9" }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ resumeTask }), + }) + + expect(resumeTask).toHaveBeenCalledWith("t9") + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("starts a new task", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const startNewTask = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "start_task", payload: { text: "go" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ startNewTask }), + }) + + expect(startNewTask).toHaveBeenCalledWith({ text: "go", images: undefined }) + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("switches mode", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const handleModeSwitch = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "switch_mode", payload: { mode: "architect" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ handleModeSwitch }), + }) + + expect(handleModeSwitch).toHaveBeenCalledWith("architect") + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index dcd70f92f1..1e72a8237e 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -194,6 +194,11 @@ vi.mock("vscode", () => { env: { isTelemetryEnabled: true, }, + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, } }) @@ -2307,3 +2312,43 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) }) + +describe("webviewMessageHandler - updateVSCodeSetting remoteControl", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("writes zoo-code.remoteControl.enabled to Global when no workspace override exists", async () => { + const update = vi.fn().mockResolvedValue(undefined) + const inspect = vi.fn().mockReturnValue({}) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ update, inspect } as never) + + await webviewMessageHandler(mockClineProvider, { + type: "updateVSCodeSetting", + setting: "zoo-code.remoteControl.enabled", + bool: true, + }) + + expect(update).toHaveBeenCalledWith("zoo-code.remoteControl.enabled", true, vscode.ConfigurationTarget.Global) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("updates an existing workspace override instead of Global", async () => { + const update = vi.fn().mockResolvedValue(undefined) + const inspect = vi.fn().mockReturnValue({ workspaceValue: false }) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ update, inspect } as never) + + await webviewMessageHandler(mockClineProvider, { + type: "updateVSCodeSetting", + setting: "zoo-code.remoteControl.enabled", + bool: true, + }) + + expect(update).toHaveBeenCalledWith( + "zoo-code.remoteControl.enabled", + true, + vscode.ConfigurationTarget.Workspace, + ) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) +}) From 7ff937c26241f0bda7492d6b7122e762d953f5a5 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:24:37 -0600 Subject: [PATCH 21/36] fix: remote steer as session mode, not a VS Code setting Start/end remote session via globalState from the task header, drop the contributed setting, preserve failed event batches newest-first, and add end_remote_session for the dashboard. --- .../__tests__/executeRemoteCommand.spec.ts | 16 +++++ .../executeRemoteCommand.ts | 8 +++ packages/core/src/zoo-remote-control/types.ts | 1 + packages/types/src/vscode-extension-host.ts | 4 +- src/core/webview/ClineProvider.ts | 4 +- .../__tests__/webviewMessageHandler.spec.ts | 40 ----------- src/core/webview/webviewMessageHandler.ts | 17 +++-- src/extension.ts | 4 ++ src/package.json | 5 -- src/package.nls.ca.json | 1 - src/package.nls.de.json | 1 - src/package.nls.es.json | 1 - src/package.nls.fr.json | 1 - src/package.nls.hi.json | 1 - src/package.nls.id.json | 1 - src/package.nls.it.json | 1 - src/package.nls.ja.json | 1 - src/package.nls.json | 1 - src/package.nls.ko.json | 1 - src/package.nls.nl.json | 1 - src/package.nls.pl.json | 1 - src/package.nls.pt-BR.json | 1 - src/package.nls.ru.json | 1 - src/package.nls.tr.json | 1 - src/package.nls.vi.json | 1 - src/package.nls.zh-CN.json | 1 - src/package.nls.zh-TW.json | 1 - .../ZooRemoteControlBridge.ts | 68 +++++++++++++------ .../__tests__/ZooRemoteControlBridge.spec.ts | 62 +++++++++-------- webview-ui/src/components/chat/TaskHeader.tsx | 54 +++++++++------ .../chat/__tests__/TaskHeader.spec.tsx | 12 ++-- webview-ui/src/i18n/locales/ca/chat.json | 11 +-- webview-ui/src/i18n/locales/de/chat.json | 11 +-- webview-ui/src/i18n/locales/en/chat.json | 9 +-- webview-ui/src/i18n/locales/es/chat.json | 11 +-- webview-ui/src/i18n/locales/fr/chat.json | 11 +-- webview-ui/src/i18n/locales/hi/chat.json | 11 +-- webview-ui/src/i18n/locales/id/chat.json | 11 +-- webview-ui/src/i18n/locales/it/chat.json | 11 +-- webview-ui/src/i18n/locales/ja/chat.json | 11 +-- webview-ui/src/i18n/locales/ko/chat.json | 11 +-- webview-ui/src/i18n/locales/nl/chat.json | 11 +-- webview-ui/src/i18n/locales/pl/chat.json | 11 +-- webview-ui/src/i18n/locales/pt-BR/chat.json | 11 +-- webview-ui/src/i18n/locales/ru/chat.json | 11 +-- webview-ui/src/i18n/locales/tr/chat.json | 11 +-- webview-ui/src/i18n/locales/vi/chat.json | 11 +-- webview-ui/src/i18n/locales/zh-CN/chat.json | 11 +-- webview-ui/src/i18n/locales/zh-TW/chat.json | 11 +-- 49 files changed, 273 insertions(+), 236 deletions(-) diff --git a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts index cb8368f3ad..08ada12ed0 100644 --- a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts @@ -13,6 +13,7 @@ function makeHost(overrides: Partial = {}): ZooRemoteComma cancelCurrentTask: vi.fn().mockResolvedValue(undefined), resumeTask: vi.fn().mockResolvedValue(undefined), startNewTask: vi.fn().mockResolvedValue(undefined), + endRemoteSession: vi.fn().mockResolvedValue(undefined), ...overrides, } } @@ -170,6 +171,21 @@ describe("executeRemoteCommand", () => { }) }) + it("acks end_remote_session and leaves the remote session", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const endRemoteSession = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "end_remote_session" }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ endRemoteSession }), + }) + + expect(endRemoteSession).toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + it("acks missing_mode for switch_mode without a mode", async () => { const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) diff --git a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts index e16cd9747e..12688f025c 100644 --- a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts +++ b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts @@ -17,6 +17,8 @@ export type ZooRemoteCommandHost = { cancelCurrentTask: () => Promise resumeTask: (taskId: string) => Promise startNewTask: (payload: { text?: string; images?: string[] }) => Promise + /** Leave remote session without cancelling the task. */ + endRemoteSession: () => Promise } async function failCommand( @@ -157,6 +159,12 @@ export async function executeRemoteCommand(options: { await host.handleModeSwitch(mode) break } + case "end_remote_session": + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.endRemoteSession() + break default: { const _exhaustive: never = command.type await failCommand(client, host, epoch, command.commandId, "unknown_command", String(_exhaustive)) diff --git a/packages/core/src/zoo-remote-control/types.ts b/packages/core/src/zoo-remote-control/types.ts index 32b8f58567..47e25d8ccd 100644 --- a/packages/core/src/zoo-remote-control/types.ts +++ b/packages/core/src/zoo-remote-control/types.ts @@ -16,6 +16,7 @@ export const zooRemoteCommandTypeSchema = z.enum([ "start_task", "switch_mode", "answer_followup", + "end_remote_session", ]) export type ZooRemoteCommandType = z.infer diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index b67ff31f8e..44d11ae8db 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -420,7 +420,7 @@ export type ExtensionState = Pick< zooCodeUserEmail?: string zooCodeUserImage?: string zooCodeBaseUrl?: string - /** VS Code setting zoo-code.remoteControl.enabled: dashboard remote steer opt-in. */ + /** True when the user has started a Zoo remote-control session from the task header. */ remoteControlEnabled?: boolean /** True when the Zoo remote-control bridge is active and the remote endpoint is reachable. */ remoteControlAvailable?: boolean @@ -514,6 +514,8 @@ export interface WebviewMessage { | "updateVSCodeSetting" | "getVSCodeSetting" | "vsCodeSetting" + | "startRemoteControlSession" + | "endRemoteControlSession" | "requestTerminalProfiles" | "openTerminalProfilePicker" | "updateCondensingPrompt" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 31268fc32c..959420e777 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2907,9 +2907,7 @@ export class ClineProvider } })(), ...zooCodeState, - remoteControlEnabled: vscode.workspace - .getConfiguration(Package.name) - .get("remoteControl.enabled", false), + remoteControlEnabled: this.context.globalState.get("zooRemoteControl.sessionActive") === true, remoteControlAvailable: getZooRemoteControlAvailable(), platform: process.platform, arch: process.arch, diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 1e72a8237e..86f02c6e74 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2312,43 +2312,3 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) }) - -describe("webviewMessageHandler - updateVSCodeSetting remoteControl", () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it("writes zoo-code.remoteControl.enabled to Global when no workspace override exists", async () => { - const update = vi.fn().mockResolvedValue(undefined) - const inspect = vi.fn().mockReturnValue({}) - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ update, inspect } as never) - - await webviewMessageHandler(mockClineProvider, { - type: "updateVSCodeSetting", - setting: "zoo-code.remoteControl.enabled", - bool: true, - }) - - expect(update).toHaveBeenCalledWith("zoo-code.remoteControl.enabled", true, vscode.ConfigurationTarget.Global) - expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() - }) - - it("updates an existing workspace override instead of Global", async () => { - const update = vi.fn().mockResolvedValue(undefined) - const inspect = vi.fn().mockReturnValue({ workspaceValue: false }) - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ update, inspect } as never) - - await webviewMessageHandler(mockClineProvider, { - type: "updateVSCodeSetting", - setting: "zoo-code.remoteControl.enabled", - bool: true, - }) - - expect(update).toHaveBeenCalledWith( - "zoo-code.remoteControl.enabled", - true, - vscode.ConfigurationTarget.Workspace, - ) - expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() - }) -}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 6fa526bfb8..1e0708ccd3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -93,7 +93,7 @@ import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" import { getCommand } from "../../utils/commands" import { getLMStudioModels } from "../../api/providers/fetchers/lmstudio" -const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv", "zoo-code.remoteControl.enabled"]) +const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) // Serializes handling of "telemetrySetting" messages. Each invocation reads the previous // setting, awaits a persistence write, then applies the new live telemetry state -- with no @@ -1947,6 +1947,16 @@ export const webviewMessageHandler = async ( stopTts() break + case "startRemoteControlSession": { + const { getZooRemoteControlBridge } = await import("../../extension") + await getZooRemoteControlBridge()?.setSessionActive(true) + break + } + case "endRemoteControlSession": { + const { getZooRemoteControlBridge } = await import("../../extension") + await getZooRemoteControlBridge()?.setSessionActive(false) + break + } case "updateVSCodeSetting": { const { setting } = message const value = message.bool ?? message.value @@ -1955,8 +1965,6 @@ export const webviewMessageHandler = async ( if (ALLOWED_VSCODE_SETTINGS.has(setting)) { const config = vscode.workspace.getConfiguration() const inspected = config.inspect(setting) - // Only touch workspace/folder when that scope already defines the key — otherwise - // write Global so enabling remote control does not create .vscode/settings.json. const target = inspected?.workspaceFolderValue !== undefined ? vscode.ConfigurationTarget.WorkspaceFolder @@ -1964,9 +1972,6 @@ export const webviewMessageHandler = async ( ? vscode.ConfigurationTarget.Workspace : vscode.ConfigurationTarget.Global await config.update(setting, value, target) - if (setting === "zoo-code.remoteControl.enabled") { - await provider.postStateToWebview() - } } else { vscode.window.showErrorMessage(`Cannot update restricted VSCode setting: ${setting}`) } diff --git a/src/extension.ts b/src/extension.ts index ce218c290d..5a1594e38f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -404,6 +404,10 @@ export async function activate(context: vscode.ExtensionContext) { let zooRemoteControlBridge: ZooRemoteControlBridge | undefined +export function getZooRemoteControlBridge(): ZooRemoteControlBridge | undefined { + return zooRemoteControlBridge +} + // This method is called when your extension is deactivated. export async function deactivate() { outputChannel.appendLine(`${Package.name} extension deactivated`) diff --git a/src/package.json b/src/package.json index f256316889..353fc40c40 100644 --- a/src/package.json +++ b/src/package.json @@ -297,11 +297,6 @@ "configuration": { "title": "%configuration.title%", "properties": { - "zoo-code.remoteControl.enabled": { - "type": "boolean", - "default": false, - "description": "%settings.remoteControl.enabled.description%" - }, "zoo-code.allowedCommands": { "type": "array", "items": { diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index 0ebcf16a66..b1397adf71 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -26,7 +26,6 @@ "command.settings.title": "Configuració", "command.documentation.title": "Documentació", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Ordres que es poden executar automàticament quan 'Aprova sempre les operacions d'execució' està activat", "commands.deniedCommands.description": "Prefixos d'ordres que seran automàticament denegats sense demanar aprovació. En cas de conflictes amb ordres permeses, la coincidència de prefix més llarga té prioritat. Afegeix * per denegar totes les ordres.", "commands.commandExecutionTimeout.description": "Temps màxim en segons per esperar que l'execució de l'ordre es completi abans d'esgotar el temps (0 = sense temps límit, 1-600s, per defecte: 0s)", diff --git a/src/package.nls.de.json b/src/package.nls.de.json index 41853893ee..b4cd8440ab 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -26,7 +26,6 @@ "command.settings.title": "Einstellungen", "command.documentation.title": "Dokumentation", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Befehle, die automatisch ausgeführt werden können, wenn 'Ausführungsoperationen immer genehmigen' aktiviert ist", "commands.deniedCommands.description": "Befehlspräfixe, die automatisch abgelehnt werden, ohne nach Genehmigung zu fragen. Bei Konflikten mit erlaubten Befehlen hat die längste Präfix-Übereinstimmung Vorrang. Füge * hinzu, um alle Befehle abzulehnen.", "commands.commandExecutionTimeout.description": "Maximale Zeit in Sekunden, die auf den Abschluss der Befehlsausführung gewartet wird, bevor ein Timeout auftritt (0 = kein Timeout, 1-600s, Standard: 0s)", diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 8f502b83c8..1d86a70980 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -26,7 +26,6 @@ "command.settings.title": "Configuración", "command.documentation.title": "Documentación", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Comandos que pueden ejecutarse automáticamente cuando 'Aprobar siempre operaciones de ejecución' está activado", "commands.deniedCommands.description": "Prefijos de comandos que serán automáticamente denegados sin solicitar aprobación. En caso de conflictos con comandos permitidos, la coincidencia de prefijo más larga tiene prioridad. Añade * para denegar todos los comandos.", "commands.commandExecutionTimeout.description": "Tiempo máximo en segundos para esperar que se complete la ejecución del comando antes de que expire (0 = sin tiempo límite, 1-600s, predeterminado: 0s)", diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 6770ee63ac..85207df975 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -26,7 +26,6 @@ "command.settings.title": "Paramètres", "command.documentation.title": "Documentation", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Commandes pouvant être exécutées automatiquement lorsque 'Toujours approuver les opérations d'exécution' est activé", "commands.deniedCommands.description": "Préfixes de commandes qui seront automatiquement refusés sans demander d'approbation. En cas de conflit avec les commandes autorisées, la correspondance de préfixe la plus longue a la priorité. Ajouter * pour refuser toutes les commandes.", "commands.commandExecutionTimeout.description": "Temps maximum en secondes pour attendre que l'exécution de la commande se termine avant expiration (0 = pas de délai, 1-600s, défaut : 0s)", diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index 3cd60be5c2..f4f283bb65 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -26,7 +26,6 @@ "command.settings.title": "सेटिंग्स", "command.documentation.title": "दस्तावेज़ीकरण", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "वे कमांड जो स्वचालित रूप से निष्पादित की जा सकती हैं जब 'हमेशा निष्पादन संचालन को स्वीकृत करें' सक्रिय हो", "commands.deniedCommands.description": "कमांड प्रीफिक्स जो स्वचालित रूप से अस्वीकार कर दिए जाएंगे बिना अनुमोदन मांगे। अनुमतित कमांड के साथ संघर्ष की स्थिति में, सबसे लंबा प्रीफिक्स मैच प्राथमिकता लेता है। सभी कमांड को अस्वीकार करने के लिए * जोड़ें।", "commands.commandExecutionTimeout.description": "कमांड निष्पादन पूरा होने का इंतजार करने के लिए अधिकतम समय सेकंड में, समय समाप्त होने से पहले (0 = कोई समय सीमा नहीं, 1-600s, डिफ़ॉल्ट: 0s)", diff --git a/src/package.nls.id.json b/src/package.nls.id.json index 7ab3ab9d8b..c49e54a34e 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -26,7 +26,6 @@ "command.showRipgrepDiagnostic.title": "Tampilkan Diagnostik Ripgrep", "command.toggleAutoApprove.title": "Alihkan Persetujuan Otomatis", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Perintah yang dapat dijalankan secara otomatis ketika 'Selalu setujui operasi eksekusi' diaktifkan", "commands.deniedCommands.description": "Awalan perintah yang akan otomatis ditolak tanpa meminta persetujuan. Jika terjadi konflik dengan perintah yang diizinkan, pencocokan awalan terpanjang akan diprioritaskan. Tambahkan * untuk menolak semua perintah.", "commands.commandExecutionTimeout.description": "Waktu maksimum dalam detik untuk menunggu eksekusi perintah selesai sebelum timeout (0 = tanpa timeout, 1-600s, default: 0s)", diff --git a/src/package.nls.it.json b/src/package.nls.it.json index 63d5b2f757..2a4b95f954 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -26,7 +26,6 @@ "command.settings.title": "Impostazioni", "command.documentation.title": "Documentazione", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Comandi che possono essere eseguiti automaticamente quando 'Approva sempre le operazioni di esecuzione' è attivato", "commands.deniedCommands.description": "Prefissi di comandi che verranno automaticamente rifiutati senza richiedere approvazione. In caso di conflitti con comandi consentiti, la corrispondenza del prefisso più lungo ha la precedenza. Aggiungi * per rifiutare tutti i comandi.", "commands.commandExecutionTimeout.description": "Tempo massimo in secondi per attendere il completamento dell'esecuzione del comando prima del timeout (0 = nessun timeout, 1-600s, predefinito: 0s)", diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index fc5566330b..0f3f4a3cee 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -26,7 +26,6 @@ "command.showRipgrepDiagnostic.title": "Ripgrep 診断を表示", "command.toggleAutoApprove.title": "自動承認を切替", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "'常に実行操作を承認する'が有効な場合に自動実行できるコマンド", "commands.deniedCommands.description": "承認を求めずに自動的に拒否されるコマンドプレフィックス。許可されたコマンドとの競合がある場合、最長プレフィックスマッチが優先されます。すべてのコマンドを拒否するには * を追加してください。", "commands.commandExecutionTimeout.description": "コマンド実行の完了を待つ最大時間(秒)、タイムアウトまで(0 = タイムアウトなし、1-600秒、デフォルト: 0秒)", diff --git a/src/package.nls.json b/src/package.nls.json index 74d19f91fc..fe10839819 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -26,7 +26,6 @@ "command.showRipgrepDiagnostic.title": "Show Ripgrep Diagnostic", "command.toggleAutoApprove.title": "Toggle Auto-Approve", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", "commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.", "commands.commandExecutionTimeout.description": "Maximum time in seconds to wait for command execution to complete before timing out (0 = no timeout, 1-600s, default: 0s)", diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index 2eb08731dc..99a064cd6c 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -26,7 +26,6 @@ "command.settings.title": "설정", "command.documentation.title": "문서", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "'항상 실행 작업 승인' 이 활성화되어 있을 때 자동으로 실행할 수 있는 명령어", "commands.deniedCommands.description": "승인을 요청하지 않고 자동으로 거부될 명령어 접두사. 허용된 명령어와 충돌하는 경우 가장 긴 접두사 일치가 우선됩니다. 모든 명령어를 거부하려면 *를 추가하세요.", "commands.commandExecutionTimeout.description": "명령어 실행이 완료되기를 기다리는 최대 시간(초), 타임아웃 전까지 (0 = 타임아웃 없음, 1-600초, 기본값: 0초)", diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 0ea8598203..27948f933a 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -26,7 +26,6 @@ "command.showRipgrepDiagnostic.title": "Ripgrep-diagnose weergeven", "command.toggleAutoApprove.title": "Auto-Goedkeuring Schakelen", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Commando's die automatisch kunnen worden uitgevoerd wanneer 'Altijd goedkeuren uitvoerbewerkingen' is ingeschakeld", "commands.deniedCommands.description": "Commando-prefixen die automatisch worden geweigerd zonder om goedkeuring te vragen. Bij conflicten met toegestane commando's heeft de langste prefix-match voorrang. Voeg * toe om alle commando's te weigeren.", "commands.commandExecutionTimeout.description": "Maximale tijd in seconden om te wachten tot commando-uitvoering voltooid is voordat er een timeout optreedt (0 = geen timeout, 1-600s, standaard: 0s)", diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 6732551d08..8fb8b6eb83 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -26,7 +26,6 @@ "command.settings.title": "Ustawienia", "command.documentation.title": "Dokumentacja", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Polecenia, które mogą być wykonywane automatycznie, gdy włączona jest opcja 'Zawsze zatwierdzaj operacje wykonania'", "commands.deniedCommands.description": "Prefiksy poleceń, które będą automatycznie odrzucane bez pytania o zatwierdzenie. W przypadku konfliktów z dozwolonymi poleceniami, najdłuższe dopasowanie prefiksu ma pierwszeństwo. Dodaj * aby odrzucić wszystkie polecenia.", "commands.commandExecutionTimeout.description": "Maksymalny czas w sekundach oczekiwania na zakończenie wykonania polecenia przed przekroczeniem limitu czasu (0 = brak limitu czasu, 1-600s, domyślnie: 0s)", diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index e15c2c6ca8..b4cae4644b 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -26,7 +26,6 @@ "command.settings.title": "Configurações", "command.documentation.title": "Documentação", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Comandos que podem ser executados automaticamente quando 'Sempre aprovar operações de execução' está ativado", "commands.deniedCommands.description": "Prefixos de comandos que serão automaticamente negados sem solicitar aprovação. Em caso de conflitos com comandos permitidos, a correspondência de prefixo mais longa tem precedência. Adicione * para negar todos os comandos.", "commands.commandExecutionTimeout.description": "Tempo máximo em segundos para aguardar a conclusão da execução do comando antes do timeout (0 = sem timeout, 1-600s, padrão: 0s)", diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index 1b9662bb54..0e61002848 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -26,7 +26,6 @@ "command.showRipgrepDiagnostic.title": "Показать диагностику Ripgrep", "command.toggleAutoApprove.title": "Переключить Авто-Подтверждение", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Команды, которые могут быть автоматически выполнены, когда включена опция 'Всегда подтверждать операции выполнения'", "commands.deniedCommands.description": "Префиксы команд, которые будут автоматически отклонены без запроса подтверждения. В случае конфликтов с разрешенными командами приоритет имеет самое длинное совпадение префикса. Добавьте * чтобы отклонить все команды.", "commands.commandExecutionTimeout.description": "Максимальное время в секундах для ожидания завершения выполнения команды до истечения времени ожидания (0 = без тайм-аута, 1-600с, по умолчанию: 0с)", diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index 2bbf661730..1842156d75 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -26,7 +26,6 @@ "command.settings.title": "Ayarlar", "command.documentation.title": "Dokümantasyon", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "'Her zaman yürütme işlemlerini onayla' etkinleştirildiğinde otomatik olarak yürütülebilen komutlar", "commands.deniedCommands.description": "Onay istenmeden otomatik olarak reddedilecek komut önekleri. İzin verilen komutlarla çakışma durumunda en uzun önek eşleşmesi öncelik alır. Tüm komutları reddetmek için * ekleyin.", "commands.commandExecutionTimeout.description": "Komut yürütmesinin tamamlanmasını beklemek için maksimum süre (saniye), zaman aşımından önce (0 = zaman aşımı yok, 1-600s, varsayılan: 0s)", diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index 83e3102f90..c846978793 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -26,7 +26,6 @@ "command.settings.title": "Cài Đặt", "command.documentation.title": "Tài Liệu", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "Các lệnh có thể được thực thi tự động khi 'Luôn phê duyệt các thao tác thực thi' được bật", "commands.deniedCommands.description": "Các tiền tố lệnh sẽ được tự động từ chối mà không yêu cầu phê duyệt. Trong trường hợp xung đột với các lệnh được phép, việc khớp tiền tố dài nhất sẽ được ưu tiên. Thêm * để từ chối tất cả các lệnh.", "commands.commandExecutionTimeout.description": "Thời gian tối đa tính bằng giây để chờ việc thực thi lệnh hoàn thành trước khi hết thời gian chờ (0 = không có thời gian chờ, 1-600s, mặc định: 0s)", diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index f13e413614..e2705e9e2f 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -26,7 +26,6 @@ "command.settings.title": "设置", "command.documentation.title": "文档", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "当启用'始终批准执行操作'时可以自动执行的命令", "commands.deniedCommands.description": "将自动拒绝而无需请求批准的命令前缀。与允许命令冲突时,最长前缀匹配优先。添加 * 拒绝所有命令。", "commands.commandExecutionTimeout.description": "等待命令执行完成的最大时间(秒),超时前(0 = 无超时,1-600秒,默认:0秒)", diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index 20c586a469..723d8d08ad 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -26,7 +26,6 @@ "command.settings.title": "設定", "command.documentation.title": "文件", "configuration.title": "Zoo Code", - "settings.remoteControl.enabled.description": "Enable remote task control when signed into Zoo. Experimental.", "commands.allowedCommands.description": "當啟用'始終批准執行操作'時可以自動執行的命令", "commands.deniedCommands.description": "將自動拒絕而無需請求批准的命令前綴。與允許命令衝突時,最長前綴匹配優先。新增 * 拒絕所有命令。", "commands.commandExecutionTimeout.description": "等待命令執行完成的最大時間(秒),逾時前(0 = 無逾時,1-600秒,預設:0秒)", diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index b90ba4d7c2..fbedeaf5b5 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -20,7 +20,6 @@ import { import type { ClineProvider } from "../../core/webview/ClineProvider" import { Package } from "../../shared/package" -import type { Mode } from "../../shared/modes" import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../zoo-code-auth" const PRESENCE_INTERVAL_MS = 15_000 @@ -31,6 +30,9 @@ const UNAVAILABLE_BACKOFF_MS = 60_000 const NOT_ELIGIBLE_BACKOFF_MS = 30_000 const INSTANCE_ID_STATE_KEY = "zooRemoteControl.instanceId" const EVENT_SEQ_STATE_KEY = "zooRemoteControl.eventSeq" +/** Built-in remote session flag (not a VS Code Configuration setting). */ +const SESSION_ACTIVE_STATE_KEY = "zooRemoteControl.sessionActive" +const LEGACY_SETTING_KEY = "remoteControl.enabled" export type ZooRemoteControlBridgeDeps = { api: Pick @@ -124,16 +126,6 @@ export class ZooRemoteControlBridge implements vscode.Disposable { log: this.log, }) - if (typeof vscode.workspace.onDidChangeConfiguration === "function") { - this.disposables.push( - vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("zoo-code.remoteControl.enabled")) { - void this.syncActiveState() - } - }), - ) - } - if (typeof this.provider.on === "function") { this.provider.on(RooCodeEventName.ProviderProfileChanged, this.onProviderProfileChanged) } @@ -148,7 +140,44 @@ export class ZooRemoteControlBridge implements vscode.Disposable { ) } - void this.syncActiveState() + void this.migrateLegacySettingThenSync() + } + + /** Test helper / webview: whether the user has an active remote session. */ + public isSessionActive(): boolean { + return this.context.globalState.get(SESSION_ACTIVE_STATE_KEY) === true + } + + /** Start or end remote session (does not cancel the task). */ + public async setSessionActive(active: boolean): Promise { + await this.context.globalState.update(SESSION_ACTIVE_STATE_KEY, active) + await this.syncActiveState() + await this.provider.postStateToWebview() + } + + private async migrateLegacySettingThenSync(): Promise { + try { + const existing = this.context.globalState.get(SESSION_ACTIVE_STATE_KEY) + if (existing === undefined) { + const legacy = + vscode.workspace.getConfiguration(Package.name).get(LEGACY_SETTING_KEY, false) === true + if (legacy) { + await this.context.globalState.update(SESSION_ACTIVE_STATE_KEY, true) + try { + await vscode.workspace + .getConfiguration(Package.name) + .update(LEGACY_SETTING_KEY, undefined, vscode.ConfigurationTarget.Global) + } catch { + // Best-effort clear of the removed setting. + } + } + } + } catch (error) { + this.log( + `[ZooRemoteControl] legacy setting migrate failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + await this.syncActiveState() } /** Awaitable teardown so offline presence can finish before extension deactivate returns. */ @@ -244,10 +273,6 @@ export class ZooRemoteControlBridge implements vscode.Disposable { }) } - private isSettingEnabled(): boolean { - return vscode.workspace.getConfiguration(Package.name).get("remoteControl.enabled", false) === true - } - private getApiProvider(): string | undefined { try { const getSettings = this.provider.contextProxy?.getProviderSettings @@ -263,7 +288,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { private evaluateEligible(): boolean { return isZooRemoteControlEligible({ - remoteControlEnabled: this.isSettingEnabled(), + remoteControlEnabled: this.isSessionActive(), apiProvider: this.getApiProvider(), sessionToken: getCachedZooCodeToken() || undefined, }) @@ -599,7 +624,9 @@ export class ZooRemoteControlBridge implements vscode.Disposable { if (result.status === 401 || result.status === 402 || result.status === 403) { this.markNotEligibleBackoff() - this.pendingEvents = [...batch.slice(-50), ...this.pendingEvents].slice(0, 200) + // Keep the failed batch ahead of anything enqueued during the await; cap from the + // tail so the newest events survive under pressure. + this.pendingEvents = [...batch, ...this.pendingEvents].slice(-200) return } @@ -610,7 +637,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } if (!result.ok) { - this.pendingEvents = [...batch.slice(-50), ...this.pendingEvents].slice(0, 200) + this.pendingEvents = [...batch, ...this.pendingEvents].slice(-200) return } @@ -672,7 +699,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { isLifecycleCurrent: (value) => this.isLifecycleCurrent(value), isEligible: () => this.evaluateEligible(), sendMessage: (text, images) => this.api.sendMessage(text, images), - handleModeSwitch: (mode) => this.provider.handleModeSwitch(mode as Mode), + handleModeSwitch: (mode) => this.provider.handleModeSwitch(mode), getCurrentTask: () => { const task = this.provider.getCurrentTask() if (!task) { @@ -690,6 +717,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { startNewTask: async (payload) => { await this.api.startNewTask(payload) }, + endRemoteSession: () => this.setSessionActive(false), }, }) } diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index cd84cc8216..4c52a140c7 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { EventEmitter } from "events" import { providerIdentifiers, RooCodeEventName } from "@roo-code/types" @@ -11,10 +11,11 @@ vi.mock("vscode", () => ({ workspace: { getConfiguration: vi.fn(() => ({ get: vi.fn((_key: string, defaultValue?: boolean) => defaultValue), + update: vi.fn().mockResolvedValue(undefined), })), - onDidChangeConfiguration: vi.fn(() => ({ dispose: vi.fn() })), workspaceFolders: [{ uri: { fsPath: "/tmp/project" } }], }, + ConfigurationTarget: { Global: 1, Workspace: 2, WorkspaceFolder: 3 }, env: { sessionId: "test-session-id", appName: "Code", @@ -52,15 +53,16 @@ describe("ZooRemoteControlBridge", () => { globalState: { get: ReturnType; update: ReturnType } secrets: { onDidChange: ReturnType } } - let getConfigValue: (key: string, defaultValue?: boolean) => boolean + let sessionActive = false beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() - getConfigValue = (_key, defaultValue) => defaultValue ?? false + sessionActive = false vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ - get: vi.fn((key: string, defaultValue?: boolean) => getConfigValue(key, defaultValue)), + get: vi.fn((_key: string, defaultValue?: boolean) => defaultValue ?? false), + update: vi.fn().mockResolvedValue(undefined), } as never) vi.mocked(getCachedZooCodeToken).mockReturnValue("") @@ -94,9 +96,14 @@ describe("ZooRemoteControlBridge", () => { get: vi.fn((key: string) => { if (key === "zooRemoteControl.instanceId") return "instance-1" if (key === "zooRemoteControl.eventSeq") return 0 + if (key === "zooRemoteControl.sessionActive") return sessionActive return undefined }), - update: vi.fn().mockResolvedValue(undefined), + update: vi.fn(async (key: string, value: unknown) => { + if (key === "zooRemoteControl.sessionActive") { + sessionActive = value === true + } + }), }, secrets: { onDidChange: vi.fn(() => ({ dispose: vi.fn() })), @@ -127,7 +134,7 @@ describe("ZooRemoteControlBridge", () => { }) it("activates when opted in with an eligible Zoo session", async () => { - getConfigValue = () => true + sessionActive = true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, @@ -152,7 +159,7 @@ describe("ZooRemoteControlBridge", () => { }) it("posts only the workspace folder basename, never an absolute path", async () => { - getConfigValue = () => true + sessionActive = true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, @@ -178,7 +185,7 @@ describe("ZooRemoteControlBridge", () => { }) it("does not activate when the provider is not eligible", async () => { - getConfigValue = () => true + sessionActive = true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.anthropic, @@ -192,7 +199,7 @@ describe("ZooRemoteControlBridge", () => { }) it("executes a remote message command and acks success", async () => { - getConfigValue = () => true + sessionActive = true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, @@ -221,7 +228,7 @@ describe("ZooRemoteControlBridge", () => { }) it("backs off when the remote endpoints return 404", async () => { - getConfigValue = () => true + sessionActive = true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, @@ -235,14 +242,14 @@ describe("ZooRemoteControlBridge", () => { client.postPresence.mockClear() await vi.advanceTimersByTimeAsync(15_000) - // Still in 404 backoff window — presence should not fire again yet. + // Still in 404 backoff window — presence should not fire again yet. expect(client.postPresence).not.toHaveBeenCalled() bridge.dispose() }) it("publishes non-partial message events while active", async () => { - getConfigValue = () => true + sessionActive = true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, @@ -273,7 +280,7 @@ describe("ZooRemoteControlBridge", () => { }) it("deactivates when the session is no longer eligible", async () => { - getConfigValue = () => true + sessionActive = true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, @@ -297,7 +304,7 @@ describe("ZooRemoteControlBridge", () => { }) async function activateEligibleBridge() { - getConfigValue = () => true + sessionActive = true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, @@ -723,29 +730,23 @@ describe("ZooRemoteControlBridge", () => { bridge.dispose() }) - it("reacts to configuration and secret changes", async () => { - let configListener: ((event: { affectsConfiguration: (key: string) => boolean }) => void) | undefined + it("reacts to session start/end and secret changes", async () => { let secretListener: ((event: { key: string }) => void) | undefined - vi.mocked(vscode.workspace.onDidChangeConfiguration).mockImplementation((listener) => { - configListener = listener as typeof configListener - return { dispose: vi.fn() } - }) context.secrets.onDidChange.mockImplementation((listener: (event: { key: string }) => void) => { secretListener = listener return { dispose: vi.fn() } }) - getConfigValue = () => false + sessionActive = false const bridge = createBridge() await vi.advanceTimersByTimeAsync(0) expect(bridge.isActive()).toBe(false) - getConfigValue = () => true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, }) - configListener?.({ affectsConfiguration: (key) => key === "zoo-code.remoteControl.enabled" }) + await bridge.setSessionActive(true) await vi.advanceTimersByTimeAsync(0) expect(bridge.isActive()).toBe(true) @@ -757,11 +758,12 @@ describe("ZooRemoteControlBridge", () => { }) it("generates and persists an instance id when none is stored", async () => { + sessionActive = true context.globalState.get.mockImplementation((key: string) => { if (key === "zooRemoteControl.eventSeq") return -1 + if (key === "zooRemoteControl.sessionActive") return sessionActive return undefined }) - getConfigValue = () => true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, @@ -776,16 +778,20 @@ describe("ZooRemoteControlBridge", () => { }) it("does not activate when instance id persistence fails", async () => { + sessionActive = true context.globalState.get.mockImplementation((key: string) => { if (key === "zooRemoteControl.eventSeq") return 0 + if (key === "zooRemoteControl.sessionActive") return sessionActive return undefined }) context.globalState.update.mockImplementation(async (key: string) => { if (key === "zooRemoteControl.instanceId") { throw new Error("storage unavailable") } + if (key === "zooRemoteControl.sessionActive") { + sessionActive = true + } }) - getConfigValue = () => true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, @@ -799,7 +805,7 @@ describe("ZooRemoteControlBridge", () => { }) it("awaits serialized event-seq persistence and stops enqueueing after persist failure", async () => { - getConfigValue = () => true + sessionActive = true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, @@ -837,7 +843,7 @@ describe("ZooRemoteControlBridge", () => { }) it("tolerates provider settings lookup failures", async () => { - getConfigValue = () => true + sessionActive = true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") provider.contextProxy.getProviderSettings.mockImplementation(() => { throw new Error("settings boom") diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 7735cec592..de186ed6e6 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -90,15 +90,15 @@ const TaskHeader = ({ vscode.postMessage({ type: "switchTab", tab: "settings" }) } - const enableRemoteSteerAndOpenDashboard = () => { - vscode.postMessage({ - type: "updateVSCodeSetting", - setting: "zoo-code.remoteControl.enabled", - bool: true, - }) + const startRemoteSessionAndOpenDashboard = () => { + vscode.postMessage({ type: "startRemoteControlSession" }) openRemoteDashboard() } + const endRemoteSession = () => { + vscode.postMessage({ type: "endRemoteControlSession" }) + } + // Calculate maxTokens (reserved for output) once for reuse in percentage and tooltip const maxTokens = useMemo( () => @@ -176,12 +176,20 @@ const TaskHeader = ({ onClick={(e) => e.stopPropagation()} data-testid="remote-steer-active"> {t("chat:task.steerRemotelyActive")} - +
+ + +
)} {canShowRemoteSteer && isZooGateway && remoteControlEnabled && !remoteControlAvailable && ( @@ -190,12 +198,20 @@ const TaskHeader = ({ onClick={(e) => e.stopPropagation()} data-testid="remote-steer-unavailable"> {t("chat:task.steerRemotelyUnavailable")} - +
+ + +
)} {canShowRemoteSteer && isZooGateway && !remoteControlEnabled && ( @@ -207,7 +223,7 @@ const TaskHeader = ({ diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 67a1b35dfb..87a86c8807 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -380,24 +380,20 @@ describe("TaskHeader", () => { mockExtensionState.clineMessages = [] }) - it("shows an immediate manual control to enable remote steer, including on completed tasks", () => { + it("shows an immediate control to start a remote session, including on completed tasks", () => { mockExtensionState.zooCodeBaseUrl = "https://www.zoocode.dev" renderTaskHeader() const offer = screen.getByTestId("remote-steer-offer") expect(offer).toHaveTextContent("chat:task.steerRemotelyHint") fireEvent.click(screen.getByRole("button", { name: "chat:task.steerRemotely" })) - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "updateVSCodeSetting", - setting: "zoo-code.remoteControl.enabled", - bool: true, - }) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "startRemoteControlSession" }) expect(mockPostMessage).toHaveBeenCalledWith({ type: "openExternal", url: "https://www.zoocode.dev/dashboard/agents/ide", }) }) - it("shows the active remote steer strip with a dashboard link", () => { + it("shows the active remote session strip with dashboard and end-session actions", () => { mockExtensionState.remoteControlEnabled = true mockExtensionState.remoteControlAvailable = true mockExtensionState.zooCodeBaseUrl = "https://www.zoocode.dev" @@ -409,6 +405,8 @@ describe("TaskHeader", () => { type: "openExternal", url: "https://www.zoocode.dev/dashboard/agents/ide", }) + fireEvent.click(screen.getByText("chat:task.endRemoteSession")) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "endRemoteControlSession" }) }) it("shows unavailable state when remote control is enabled but the bridge is down", () => { diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 23d646f807..1e15be43ec 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Benvingut a Zoo Code!", "task": { "title": "Tasca", @@ -21,12 +21,13 @@ "waitingOnSubtask": "Esperant subtasca", "goToSubtask": "Anar a la subtasca", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Desfixar", "pin": "Fixar", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 243db71ffa..09123c8d0e 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Willkommen bei Zoo Code!", "task": { "title": "Aufgabe", @@ -21,12 +21,13 @@ "waitingOnSubtask": "Wartet auf Unteraufgabe", "goToSubtask": "Zur Unteraufgabe", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Lösen von oben", "pin": "Anheften", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index d718835e76..07a35b055e 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -21,12 +21,13 @@ "waitingOnSubtask": "Waiting on subtask", "goToSubtask": "Go to subtask", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Unpin", "pin": "Pin", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 9777835e95..62e99349a1 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "¡Bienvenido a Zoo Code!", "task": { "title": "Tarea", @@ -21,12 +21,13 @@ "waitingOnSubtask": "Esperando subtarea", "goToSubtask": "Ir a la subtarea", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Desfijar", "pin": "Fijar", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 1d103a86d4..7ddf842dd8 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Bienvenue sur Zoo Code !", "task": { "title": "Tâche", @@ -21,12 +21,13 @@ "waitingOnSubtask": "En attente de sous-tâche", "goToSubtask": "Aller à la sous-tâche", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Désépingler", "pin": "Épingler", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 4ec7393685..508c55d485 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Zoo Code में आपका स्वागत है!", "task": { "title": "कार्य", @@ -21,12 +21,13 @@ "waitingOnSubtask": "उपकार्य की प्रतीक्षा", "goToSubtask": "उपकार्य पर जाएं", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "पिन करें", "pin": "अवपिन करें", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 66250359fd..74b2126e00 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Selamat datang di Zoo Code!", "task": { "title": "Tugas", @@ -21,12 +21,13 @@ "waitingOnSubtask": "Menunggu subtugas", "goToSubtask": "Pergi ke subtugas", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "history": { "title": "Riwayat" diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 22f2231843..020d686d92 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Benvenuto in Zoo Code!", "task": { "title": "Attività", @@ -21,12 +21,13 @@ "waitingOnSubtask": "In attesa di sottoattività", "goToSubtask": "Vai alla sottoattività", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Rilascia", "pin": "Fissa", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 24b26b326b..6f06a5c79d 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Zoo Code へようこそ!", "task": { "title": "タスク", @@ -21,12 +21,13 @@ "waitingOnSubtask": "サブタスク待ち", "goToSubtask": "サブタスクへ", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "ピン留めを解除", "pin": "ピン留め", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 946200a3cd..7697fb993f 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Zoo Code에 오신 것을 환영합니다!", "task": { "title": "작업", @@ -21,12 +21,13 @@ "waitingOnSubtask": "하위 작업 대기 중", "goToSubtask": "하위 작업으로 이동", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "고정 해제하기", "pin": "고정하기", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index a7cdde5355..72f05698c6 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Welkom bij Zoo Code!", "task": { "title": "Taak", @@ -21,12 +21,13 @@ "waitingOnSubtask": "Wacht op subtaak", "goToSubtask": "Ga naar subtaak", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Losmaken", "pin": "Vastmaken", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index b8735d8884..1b89b12181 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Witamy w Zoo Code!", "task": { "title": "Zadanie", @@ -21,12 +21,13 @@ "waitingOnSubtask": "Oczekuje na podzadanie", "goToSubtask": "Przejdź do podzadania", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Odepnij", "pin": "Przypnij", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index f01e73394a..654f8106e5 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Bem-vindo ao Zoo Code!", "task": { "title": "Tarefa", @@ -21,12 +21,13 @@ "waitingOnSubtask": "Aguardando subtarefa", "goToSubtask": "Ir para subtarefa", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Desfixar", "pin": "Fixar", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 86ab085a2d..d139ff15b3 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Добро пожаловать в Zoo Code!", "task": { "title": "Задача", @@ -21,12 +21,13 @@ "waitingOnSubtask": "Ожидание подзадачи", "goToSubtask": "Перейти к подзадаче", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Открепить", "pin": "Закрепить", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 5c286e29ad..898aa41b6a 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Zoo Code'a hoş geldin!", "task": { "title": "Görev", @@ -21,12 +21,13 @@ "waitingOnSubtask": "Alt görev bekleniyor", "goToSubtask": "Alt göreve git", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Sabitlemeyi iptal et", "pin": "Sabitle", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 7e6c890188..1233ceb0c7 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Chào mừng đến với Zoo Code!", "task": { "title": "Nhiệm vụ", @@ -21,12 +21,13 @@ "waitingOnSubtask": "Đang chờ nhiệm vụ con", "goToSubtask": "Đến nhiệm vụ con", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Bỏ ghim khỏi đầu", "pin": "Ghim lên đầu", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 9a7ed85f08..ca5e2ac410 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "欢迎使用 Zoo Code!", "task": { "title": "任务", @@ -21,12 +21,13 @@ "waitingOnSubtask": "等待子任务", "goToSubtask": "前往子任务", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "取消置顶", "pin": "置顶", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index efbe91e27a..80e6228ffa 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "歡迎使用 Zoo Code!", "task": { "title": "工作", @@ -21,12 +21,13 @@ "waitingOnSubtask": "等待子任務", "goToSubtask": "前往子任務", "steerRemotely": "Steer this task remotely", - "steerRemotelyHint": "Enables remote control for this workspace and opens the dashboard so you can approve, reply, and continue from there.", + "steerRemotelyHint": "Opens the Zoo dashboard so you can approve, reply, and continue this task from there.", "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", "steerRemotelyOpenSettings": "Open settings", - "steerRemotelyActive": "Remote steer is on, control this task from the Zoo dashboard.", - "steerRemotelyUnavailable": "Remote steer is enabled, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", - "openRemoteDashboard": "Open dashboard" + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "取消釘選", "pin": "釘選", From 19d822f12dc75aa481c563dc63fb1811a7e8645e Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:37:35 -0600 Subject: [PATCH 22/36] fix: clear UTF-8 BOMs and shrink remote-control mutation surface PowerShell UTF-8 writes had left BOMs that broke invisible-chars and locale JSON parsing. Move pure bridge helpers into core so the extension package stays under the 500-line mutation gate. --- .../__tests__/bridgeHelpers.spec.ts | 47 ++++++++ .../src/zoo-remote-control/bridgeHelpers.ts | 53 ++++++++++ packages/core/src/zoo-remote-control/index.ts | 1 + .../ZooRemoteControlBridge.ts | 100 +++++++----------- .../__tests__/ZooRemoteControlBridge.spec.ts | 2 +- webview-ui/src/i18n/locales/ca/chat.json | 2 +- webview-ui/src/i18n/locales/de/chat.json | 2 +- webview-ui/src/i18n/locales/es/chat.json | 2 +- webview-ui/src/i18n/locales/fr/chat.json | 2 +- webview-ui/src/i18n/locales/hi/chat.json | 2 +- webview-ui/src/i18n/locales/id/chat.json | 2 +- webview-ui/src/i18n/locales/it/chat.json | 2 +- webview-ui/src/i18n/locales/ja/chat.json | 2 +- webview-ui/src/i18n/locales/ko/chat.json | 2 +- webview-ui/src/i18n/locales/nl/chat.json | 2 +- webview-ui/src/i18n/locales/pl/chat.json | 2 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 2 +- webview-ui/src/i18n/locales/ru/chat.json | 2 +- webview-ui/src/i18n/locales/tr/chat.json | 2 +- webview-ui/src/i18n/locales/vi/chat.json | 2 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 2 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 2 +- 22 files changed, 157 insertions(+), 80 deletions(-) create mode 100644 packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts create mode 100644 packages/core/src/zoo-remote-control/bridgeHelpers.ts diff --git a/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts b/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts new file mode 100644 index 0000000000..f358849af1 --- /dev/null +++ b/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest" + +import { + isRemoteAuthBlockedStatus, + isRemoteNotFoundStatus, + partialFlushDecision, + remoteMessageKey, + requeueRemoteEventsKeepingNewest, + resolveRemoteMessageAction, + workspaceNicknameFromFsPath, +} from "../bridgeHelpers.js" + +describe("bridgeHelpers", () => { + it("derives a nickname from posix and windows paths", () => { + expect(workspaceNicknameFromFsPath("/Users/me/proj/")).toBe("proj") + expect(workspaceNicknameFromFsPath("C:\\Users\\me\\proj\\")).toBe("proj") + expect(workspaceNicknameFromFsPath(undefined)).toBeUndefined() + }) + + it("prefers messageId for remote message keys", () => { + expect(remoteMessageKey("t1", { messageId: "m1", ts: 9 })).toBe("m1") + expect(remoteMessageKey("t1", { ts: 9 })).toBe("t1:9") + }) + + it("resolves message actions from explicit values or partial flag", () => { + expect(resolveRemoteMessageAction("created", true)).toBe("created") + expect(resolveRemoteMessageAction(undefined, true)).toBe("updated") + expect(resolveRemoteMessageAction(undefined, false)).toBe("created") + }) + + it("requeues failed events and keeps the newest under the cap", () => { + expect(requeueRemoteEventsKeepingNewest([1, 2], [3, 4], 3)).toEqual([2, 3, 4]) + }) + + it("decides when partial flushes should arm, wait, or flush", () => { + expect(partialFlushDecision(0, 100, 75)).toBe("arm") + expect(partialFlushDecision(100, 150, 75)).toBe("wait") + expect(partialFlushDecision(100, 200, 75)).toBe("flush") + }) + + it("classifies remote HTTP statuses", () => { + expect(isRemoteAuthBlockedStatus(401)).toBe(true) + expect(isRemoteAuthBlockedStatus(404)).toBe(false) + expect(isRemoteNotFoundStatus(404)).toBe(true) + expect(isRemoteNotFoundStatus(500)).toBe(false) + }) +}) diff --git a/packages/core/src/zoo-remote-control/bridgeHelpers.ts b/packages/core/src/zoo-remote-control/bridgeHelpers.ts new file mode 100644 index 0000000000..984d3cfdc6 --- /dev/null +++ b/packages/core/src/zoo-remote-control/bridgeHelpers.ts @@ -0,0 +1,53 @@ +/** Nickname-only workspace label — never an absolute disk path. */ +export function workspaceNicknameFromFsPath(workspaceRoot: string | undefined) { + if (!workspaceRoot) { + return undefined + } + + return ( + workspaceRoot + .replace(/[/\\]+$/, "") + .split(/[/\\]/) + .pop() || undefined + ) +} + +export function remoteMessageKey(taskId: string, message: { messageId?: string; ts: number }) { + return message.messageId ?? `${taskId}:${message.ts}` +} + +export function resolveRemoteMessageAction( + action: string | undefined, + partial: boolean | undefined, +): "created" | "updated" { + if (action === "created" || action === "updated") { + return action + } + + return partial ? "updated" : "created" +} + +/** Keep failed batch ahead of newer events; retain the newest `max` under pressure. */ +export function requeueRemoteEventsKeepingNewest(failedBatch: T[], pending: T[], max = 200) { + return [...failedBatch, ...pending].slice(-max) +} + +export function partialFlushDecision( + lastPartialFlushAt: number, + now: number, + minIntervalMs: number, +): "arm" | "wait" | "flush" { + if (lastPartialFlushAt === 0) { + return "arm" + } + + return now - lastPartialFlushAt < minIntervalMs ? "wait" : "flush" +} + +export function isRemoteAuthBlockedStatus(status: number | undefined) { + return status === 401 || status === 402 || status === 403 +} + +export function isRemoteNotFoundStatus(status: number | undefined) { + return status === 404 +} diff --git a/packages/core/src/zoo-remote-control/index.ts b/packages/core/src/zoo-remote-control/index.ts index 0c773fd7dc..5599fc1592 100644 --- a/packages/core/src/zoo-remote-control/index.ts +++ b/packages/core/src/zoo-remote-control/index.ts @@ -1,5 +1,6 @@ export * from "./eligibility.js" export * from "./availability.js" +export * from "./bridgeHelpers.js" export * from "./executeRemoteCommand.js" export * from "./projectMessage.js" export * from "./types.js" diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index fbedeaf5b5..6a45c34cf3 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -2,10 +2,17 @@ import * as vscode from "vscode" import { executeRemoteCommand, + isRemoteAuthBlockedStatus, + isRemoteNotFoundStatus, isZooRemoteControlEligible, + partialFlushDecision, projectClineMessageForRemote, projectClineMessagesForSnapshot, + remoteMessageKey, + requeueRemoteEventsKeepingNewest, + resolveRemoteMessageAction, setZooRemoteControlAvailable, + workspaceNicknameFromFsPath, ZooRemoteControlClient, type ZooRemoteCommand, type ZooRemoteEvent, @@ -391,10 +398,6 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.api[method](RooCodeEventName.TaskModeSwitched, this.onModeSwitched) } - private messageKey(taskId: string, message: ClineMessage): string { - return message.messageId ?? `${taskId}:${message.ts}` - } - private async handleMessageEvent(payload: { taskId: string action?: string @@ -410,24 +413,24 @@ export class ZooRemoteControlBridge implements vscode.Disposable { message: projectClineMessageForRemote(payload.message), } - const isPartial = shaped.message.partial === true - if (isPartial) { - this.pendingPartials.set(this.messageKey(shaped.taskId, shaped.message), shaped) + const key = remoteMessageKey(shaped.taskId, shaped.message) + if (shaped.message.partial === true) { + this.pendingPartials.set(key, shaped) // Start the throttle window on the first partial without flushing so rapid // updates to the same message coalesce to the latest text. - if (this.lastPartialFlushAt === 0) { + const decision = partialFlushDecision(this.lastPartialFlushAt, this.now(), PARTIAL_EVENT_MIN_INTERVAL_MS) + if (decision === "arm") { this.lastPartialFlushAt = this.now() return } - const elapsed = this.now() - this.lastPartialFlushAt - if (elapsed < PARTIAL_EVENT_MIN_INTERVAL_MS) { + if (decision === "wait") { return } this.flushPendingPartials() return } - this.pendingPartials.delete(this.messageKey(shaped.taskId, shaped.message)) + this.pendingPartials.delete(key) this.enqueueMessageEvent(shaped) } @@ -443,17 +446,10 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private enqueueMessageEvent(shaped: { taskId: string; action?: string; message: ClineMessage }): void { - const action = - shaped.action === "created" || shaped.action === "updated" - ? shaped.action - : shaped.message.partial - ? "updated" - : "created" - this.enqueueEvent({ type: "message", taskId: shaped.taskId, - action, + action: resolveRemoteMessageAction(shaped.action, shaped.message.partial), message: shaped.message, timestamp: this.now(), }) @@ -468,15 +464,9 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.persistEventSeq() } - private markUnavailable(): void { - this.serverAvailable = false - this.unavailableUntil = this.now() + UNAVAILABLE_BACKOFF_MS - this.publishAvailabilityToWebview() - } - - private markNotEligibleBackoff(): void { + private markBackoff(ms: number): void { this.serverAvailable = false - this.unavailableUntil = this.now() + NOT_ELIGIBLE_BACKOFF_MS + this.unavailableUntil = this.now() + ms this.publishAvailabilityToWebview() } @@ -488,12 +478,8 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.publishAvailabilityToWebview() } - private isServerAvailable(): boolean { - return this.serverAvailable || this.now() >= this.unavailableUntil - } - private publishAvailabilityToWebview(): void { - const available = this.active && this.isServerAvailable() + const available = this.active && this.canCallServer() if (this.lastPostedAvailability === available) { return } @@ -503,10 +489,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private canCallServer(): boolean { - if (this.serverAvailable) { - return true - } - return this.now() >= this.unavailableUntil + return this.serverAvailable || this.now() >= this.unavailableUntil } private async publishPresence(online: boolean, options?: { force?: boolean }): Promise { @@ -518,14 +501,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } // Nickname only — never send an absolute disk path (home dir / username fingerprint). - // Split on both separators so Windows paths stay safe when unit tests run on Linux CI. - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath - const workspacePath = workspaceRoot - ? workspaceRoot - .replace(/[/\\]+$/, "") - .split(/[/\\]/) - .pop() || undefined - : undefined + const workspacePath = workspaceNicknameFromFsPath(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath) const taskId = this.provider.getCurrentTask()?.taskId const epoch = this.lifecycleEpoch const result = await this.client.postPresence({ @@ -548,16 +524,16 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - if (result.status === 401 || result.status === 402 || result.status === 403) { - this.markNotEligibleBackoff() + if (isRemoteAuthBlockedStatus(result.status)) { + this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) if (this.active) { await this.deactivate({ notifyOffline: false }) } return } - if (result.status === 404) { - this.markUnavailable() + if (isRemoteNotFoundStatus(result.status)) { + this.markBackoff(UNAVAILABLE_BACKOFF_MS) return } @@ -590,13 +566,13 @@ export class ZooRemoteControlBridge implements vscode.Disposable { mode, }) - if (result.status === 401 || result.status === 402 || result.status === 403) { - this.markNotEligibleBackoff() + if (isRemoteAuthBlockedStatus(result.status)) { + this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) return } - if (result.status === 404) { - this.markUnavailable() + if (isRemoteNotFoundStatus(result.status)) { + this.markBackoff(UNAVAILABLE_BACKOFF_MS) return } @@ -622,22 +598,22 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - if (result.status === 401 || result.status === 402 || result.status === 403) { - this.markNotEligibleBackoff() + if (isRemoteAuthBlockedStatus(result.status)) { + this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) // Keep the failed batch ahead of anything enqueued during the await; cap from the // tail so the newest events survive under pressure. - this.pendingEvents = [...batch, ...this.pendingEvents].slice(-200) + this.pendingEvents = requeueRemoteEventsKeepingNewest(batch, this.pendingEvents) return } - if (result.status === 404) { - this.markUnavailable() + if (isRemoteNotFoundStatus(result.status)) { + this.markBackoff(UNAVAILABLE_BACKOFF_MS) // Drop batch while the public endpoint is not available yet. return } if (!result.ok) { - this.pendingEvents = [...batch, ...this.pendingEvents].slice(-200) + this.pendingEvents = requeueRemoteEventsKeepingNewest(batch, this.pendingEvents) return } @@ -662,14 +638,14 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - if (result.status === 401 || result.status === 402 || result.status === 403) { - this.markNotEligibleBackoff() + if (isRemoteAuthBlockedStatus(result.status)) { + this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) await this.deactivate({ notifyOffline: false }) return } - if (result.status === 404) { - this.markUnavailable() + if (isRemoteNotFoundStatus(result.status)) { + this.markBackoff(UNAVAILABLE_BACKOFF_MS) return } diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index 4c52a140c7..03dc66558b 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { EventEmitter } from "events" import { providerIdentifiers, RooCodeEventName } from "@roo-code/types" diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 1e15be43ec..c511050024 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Benvingut a Zoo Code!", "task": { "title": "Tasca", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 09123c8d0e..b1fbbc8953 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Willkommen bei Zoo Code!", "task": { "title": "Aufgabe", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 62e99349a1..205eccd7e8 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "¡Bienvenido a Zoo Code!", "task": { "title": "Tarea", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 7ddf842dd8..181b87b730 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Bienvenue sur Zoo Code !", "task": { "title": "Tâche", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 508c55d485..9d42f6c93f 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Zoo Code में आपका स्वागत है!", "task": { "title": "कार्य", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 74b2126e00..b7acf3b9d3 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Selamat datang di Zoo Code!", "task": { "title": "Tugas", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 020d686d92..f0a3eab5d8 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Benvenuto in Zoo Code!", "task": { "title": "Attività", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 6f06a5c79d..e036ebf14a 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Zoo Code へようこそ!", "task": { "title": "タスク", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 7697fb993f..2493cc2c6b 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Zoo Code에 오신 것을 환영합니다!", "task": { "title": "작업", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 72f05698c6..71815ff241 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Welkom bij Zoo Code!", "task": { "title": "Taak", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 1b89b12181..e7649e8860 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Witamy w Zoo Code!", "task": { "title": "Zadanie", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 654f8106e5..19c5cd87ef 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Bem-vindo ao Zoo Code!", "task": { "title": "Tarefa", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index d139ff15b3..952b213bbf 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Добро пожаловать в Zoo Code!", "task": { "title": "Задача", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 898aa41b6a..d4f43f5573 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Zoo Code'a hoş geldin!", "task": { "title": "Görev", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 1233ceb0c7..14a13000b2 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "Chào mừng đến với Zoo Code!", "task": { "title": "Nhiệm vụ", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index ca5e2ac410..9983e07275 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "欢迎使用 Zoo Code!", "task": { "title": "任务", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 80e6228ffa..050ccd7d42 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -1,4 +1,4 @@ -{ +{ "greeting": "歡迎使用 Zoo Code!", "task": { "title": "工作", From 56be30dd2a706a588b20a156e281559cbb32183c Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:52:50 -0600 Subject: [PATCH 23/36] fix: avoid ReDoS in workspace nickname helper Replace trailing path-separator regex with charCode trimming so CodeQL js/polynomial-redos no longer flags bridgeHelpers. --- .../__tests__/bridgeHelpers.spec.ts | 1 + .../src/zoo-remote-control/bridgeHelpers.ts | 30 +++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts b/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts index f358849af1..48c4fb5c6a 100644 --- a/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts @@ -14,6 +14,7 @@ describe("bridgeHelpers", () => { it("derives a nickname from posix and windows paths", () => { expect(workspaceNicknameFromFsPath("/Users/me/proj/")).toBe("proj") expect(workspaceNicknameFromFsPath("C:\\Users\\me\\proj\\")).toBe("proj") + expect(workspaceNicknameFromFsPath("///")).toBeUndefined() expect(workspaceNicknameFromFsPath(undefined)).toBeUndefined() }) diff --git a/packages/core/src/zoo-remote-control/bridgeHelpers.ts b/packages/core/src/zoo-remote-control/bridgeHelpers.ts index 984d3cfdc6..f22795cb63 100644 --- a/packages/core/src/zoo-remote-control/bridgeHelpers.ts +++ b/packages/core/src/zoo-remote-control/bridgeHelpers.ts @@ -4,12 +4,30 @@ export function workspaceNicknameFromFsPath(workspaceRoot: string | undefined) { return undefined } - return ( - workspaceRoot - .replace(/[/\\]+$/, "") - .split(/[/\\]/) - .pop() || undefined - ) + // Avoid trailing-separator regexes (CodeQL js/polynomial-redos on long `/` runs). + let end = workspaceRoot.length + while (end > 0) { + const code = workspaceRoot.charCodeAt(end - 1) + if (code !== 47 /* / */ && code !== 92 /* \ */) { + break + } + end -= 1 + } + if (end === 0) { + return undefined + } + + let start = 0 + for (let i = end - 1; i >= 0; i -= 1) { + const code = workspaceRoot.charCodeAt(i) + if (code === 47 || code === 92) { + start = i + 1 + break + } + } + + const nickname = workspaceRoot.slice(start, end) + return nickname.length > 0 ? nickname : undefined } export function remoteMessageKey(taskId: string, message: { messageId?: string; ts: number }) { From 68def5ae97b763036c797da73cd60ea592ba7a6c Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:38:36 -0600 Subject: [PATCH 24/36] fix: address CodeRabbit remote-control review follow-ups Guard snapshot availability writes with lifecycle epoch, harden session toggle error handling, dedupe remote commandIds for start_task, and expand focused regression coverage. --- .../__tests__/bridgeHelpers.spec.ts | 4 + .../__tests__/webviewMessageHandler.spec.ts | 85 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 21 +++-- .../ZooRemoteControlBridge.ts | 15 ++++ .../__tests__/ZooRemoteControlBridge.spec.ts | 31 +++++++ .../chat/__tests__/TaskHeader.spec.tsx | 31 +++++++ 6 files changed, 180 insertions(+), 7 deletions(-) diff --git a/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts b/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts index 48c4fb5c6a..7c6cdf9439 100644 --- a/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts @@ -41,8 +41,12 @@ describe("bridgeHelpers", () => { it("classifies remote HTTP statuses", () => { expect(isRemoteAuthBlockedStatus(401)).toBe(true) + expect(isRemoteAuthBlockedStatus(402)).toBe(true) + expect(isRemoteAuthBlockedStatus(403)).toBe(true) expect(isRemoteAuthBlockedStatus(404)).toBe(false) + expect(isRemoteAuthBlockedStatus(undefined)).toBe(false) expect(isRemoteNotFoundStatus(404)).toBe(true) expect(isRemoteNotFoundStatus(500)).toBe(false) + expect(isRemoteNotFoundStatus(undefined)).toBe(false) }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index e13c4d2ecc..6be87b5d25 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -67,6 +67,17 @@ vi.mock("../rulesMessageHandler", () => ({ handleOpenRulesDirectory: vi.fn(), })) +const mockSetSessionActive = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) +const mockGetZooRemoteControlBridge = vi.hoisted(() => + vi.fn((): { setSessionActive: typeof mockSetSessionActive } | undefined => ({ + setSessionActive: mockSetSessionActive, + })), +) + +vi.mock("../../../extension", () => ({ + getZooRemoteControlBridge: () => mockGetZooRemoteControlBridge(), +})) + vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { hasInstance: vi.fn().mockReturnValue(false), @@ -2373,3 +2384,77 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) }) + +describe("webviewMessageHandler - remote control session", () => { + beforeEach(() => { + vi.clearAllMocks() + mockSetSessionActive.mockResolvedValue(undefined) + mockGetZooRemoteControlBridge.mockImplementation(() => ({ setSessionActive: mockSetSessionActive })) + }) + + it("starts and ends a remote session through the bridge", async () => { + await webviewMessageHandler(mockClineProvider, { type: "startRemoteControlSession" }) + await webviewMessageHandler(mockClineProvider, { type: "endRemoteControlSession" }) + + expect(mockSetSessionActive).toHaveBeenNthCalledWith(1, true) + expect(mockSetSessionActive).toHaveBeenNthCalledWith(2, false) + }) + + it("logs and completes when the bridge is unavailable", async () => { + mockGetZooRemoteControlBridge.mockReturnValue(undefined) + + await expect( + webviewMessageHandler(mockClineProvider, { type: "startRemoteControlSession" }), + ).resolves.not.toThrow() + expect(mockSetSessionActive).not.toHaveBeenCalled() + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("bridge unavailable")) + }) + + it("selects the narrowest ConfigurationTarget for updateVSCodeSetting", async () => { + const update = vi.fn().mockResolvedValue(undefined) + const inspect = vi + .fn() + .mockReturnValueOnce({ workspaceFolderValue: true }) + .mockReturnValueOnce({ workspaceValue: false }) + .mockReturnValueOnce({ globalValue: undefined }) + + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + inspect, + update, + get: vi.fn(), + } as never) + + await webviewMessageHandler(mockClineProvider, { + type: "updateVSCodeSetting", + setting: "terminal.integrated.inheritEnv", + bool: true, + }) + expect(update).toHaveBeenLastCalledWith( + "terminal.integrated.inheritEnv", + true, + vscode.ConfigurationTarget.WorkspaceFolder, + ) + + await webviewMessageHandler(mockClineProvider, { + type: "updateVSCodeSetting", + setting: "terminal.integrated.inheritEnv", + bool: false, + }) + expect(update).toHaveBeenLastCalledWith( + "terminal.integrated.inheritEnv", + false, + vscode.ConfigurationTarget.Workspace, + ) + + await webviewMessageHandler(mockClineProvider, { + type: "updateVSCodeSetting", + setting: "terminal.integrated.inheritEnv", + bool: true, + }) + expect(update).toHaveBeenLastCalledWith( + "terminal.integrated.inheritEnv", + true, + vscode.ConfigurationTarget.Global, + ) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index de054cb430..d62b1d8ac9 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1976,14 +1976,21 @@ export const webviewMessageHandler = async ( stopTts() break - case "startRemoteControlSession": { - const { getZooRemoteControlBridge } = await import("../../extension") - await getZooRemoteControlBridge()?.setSessionActive(true) - break - } + case "startRemoteControlSession": case "endRemoteControlSession": { - const { getZooRemoteControlBridge } = await import("../../extension") - await getZooRemoteControlBridge()?.setSessionActive(false) + try { + const { getZooRemoteControlBridge } = await import("../../extension") + const bridge = getZooRemoteControlBridge() + if (!bridge) { + provider.log("[ZooRemoteControl] session toggle ignored: bridge unavailable") + break + } + await bridge.setSessionActive(message.type === "startRemoteControlSession") + } catch (error) { + provider.log( + `[ZooRemoteControl] session toggle failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } break } case "updateVSCodeSetting": { diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index 6a45c34cf3..45d12c1c0c 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -77,6 +77,8 @@ export class ZooRemoteControlBridge implements vscode.Disposable { /** Bumped on deactivate/dispose so in-flight polls/flushes become no-ops. */ private lifecycleEpoch = 0 private commandPollInFlight = false + /** At-most-once delivery for remote commands (esp. start_task) within an active session. */ + private handledCommandIds = new Set() private presenceTimer: ReturnType | undefined private commandTimer: ReturnType | undefined @@ -362,6 +364,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { this.bindApiListeners(false) this.pendingEvents = [] this.pendingPartials.clear() + this.handledCommandIds.clear() this.publishAvailabilityToWebview() if (notifyOffline) { @@ -559,12 +562,16 @@ export class ZooRemoteControlBridge implements vscode.Disposable { mode = undefined } + const epoch = this.lifecycleEpoch const result = await this.client.postSnapshot({ instanceId: this.instanceId, taskId: task.taskId, messages: projectClineMessagesForSnapshot(task.clineMessages), mode, }) + if (!this.isLifecycleCurrent(epoch)) { + return + } if (isRemoteAuthBlockedStatus(result.status)) { this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) @@ -667,6 +674,13 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private async executeCommand(command: ZooRemoteCommand, epoch: number): Promise { + if (this.handledCommandIds.has(command.commandId)) { + if (this.isLifecycleCurrent(epoch)) { + await this.client.ackCommand({ commandId: command.commandId, status: "acked" }) + } + return + } + await executeRemoteCommand({ command, epoch, @@ -696,5 +710,6 @@ export class ZooRemoteControlBridge implements vscode.Disposable { endRemoteSession: () => this.setSessionActive(false), }, }) + this.handledCommandIds.add(command.commandId) } } diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index 03dc66558b..439d755a67 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -443,6 +443,33 @@ describe("ZooRemoteControlBridge", () => { bridge.dispose() }) + it("acks duplicate start_task commandIds without starting another task", async () => { + client.getCommands + .mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [{ commandId: "dup-start", type: "start_task", payload: { text: "first" } }], + }, + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [{ commandId: "dup-start", type: "start_task", payload: { text: "again" } }], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(2_000) + + expect(api.startNewTask).toHaveBeenCalledTimes(1) + expect(api.startNewTask).toHaveBeenCalledWith({ text: "first", images: undefined }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "dup-start", status: "acked" }) + bridge.dispose() + }) + it("acks execution_failed when a command throws", async () => { api.sendMessage.mockRejectedValueOnce(new Error("boom")) client.getCommands.mockResolvedValueOnce({ @@ -870,6 +897,10 @@ describe("ZooRemoteControlBridge", () => { const bridge = await activateEligibleBridge() await vi.advanceTimersByTimeAsync(0) expect(client.postSnapshot).toHaveBeenCalled() + // Snapshot 404 shares the 60s unavailable backoff — presence must not fire again yet. + client.postPresence.mockClear() + await vi.advanceTimersByTimeAsync(15_000) + expect(client.postPresence).not.toHaveBeenCalled() bridge.dispose() }) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 87a86c8807..e96f869c83 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -364,6 +364,7 @@ describe("TaskHeader", () => { mockExtensionState.remoteControlEnabled = false mockExtensionState.remoteControlAvailable = false mockExtensionState.zooCodeBaseUrl = undefined + mockExtensionState.currentTaskItem = { id: "test-task-id" } mockExtensionState.clineMessages = [{ type: "say", say: "completion_result", ts: 1, text: "done" }] }) @@ -377,6 +378,7 @@ describe("TaskHeader", () => { mockExtensionState.remoteControlEnabled = false mockExtensionState.remoteControlAvailable = false mockExtensionState.zooCodeBaseUrl = undefined + mockExtensionState.currentTaskItem = { id: "test-task-id" } mockExtensionState.clineMessages = [] }) @@ -434,5 +436,34 @@ describe("TaskHeader", () => { fireEvent.click(screen.getByRole("button", { name: "chat:task.steerRemotelyOpenSettings" })) expect(mockPostMessage).toHaveBeenCalledWith({ type: "switchTab", tab: "settings" }) }) + + it("hides all remote-steer banners when unauthenticated or there is no current task", () => { + mockExtensionState.zooCodeIsAuthenticated = false + renderTaskHeader() + expect(screen.queryByTestId("remote-steer-offer")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-active")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-unavailable")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-needs-gateway")).not.toBeInTheDocument() + + mockExtensionState.zooCodeIsAuthenticated = true + mockExtensionState.currentTaskItem = null as never + renderTaskHeader() + expect(screen.queryByTestId("remote-steer-offer")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-active")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-unavailable")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-needs-gateway")).not.toBeInTheDocument() + }) + + it("normalizes a trailing slash on the Zoo base URL for dashboard links", () => { + mockExtensionState.remoteControlEnabled = true + mockExtensionState.remoteControlAvailable = true + mockExtensionState.zooCodeBaseUrl = "https://www.zoocode.dev/" + renderTaskHeader() + fireEvent.click(screen.getByText("chat:task.openRemoteDashboard")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openExternal", + url: "https://www.zoocode.dev/dashboard/agents/ide", + }) + }) }) }) From b4be1d5e65f8662e0d34f6582df2c0715107f79f Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:58:24 -0600 Subject: [PATCH 25/36] fix: keep remote-control extension under mutation line gate Move HTTP/id helpers and commandId dedupe into packages/core so the extension package stays at or below 500 changed executable lines. --- .../__tests__/bridgeHelpers.spec.ts | 15 +++ .../__tests__/executeRemoteCommand.spec.ts | 25 ++++ .../src/zoo-remote-control/bridgeHelpers.ts | 23 ++++ .../executeRemoteCommand.ts | 11 +- .../__tests__/webviewMessageHandler.spec.ts | 4 +- src/core/webview/webviewMessageHandler.ts | 7 +- .../ZooRemoteControlBridge.ts | 108 +++++++----------- 7 files changed, 116 insertions(+), 77 deletions(-) diff --git a/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts b/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts index 7c6cdf9439..039cc806fd 100644 --- a/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from "vitest" import { + classifyRemoteControlHttpResult, + generateZooRemoteInstanceId, isRemoteAuthBlockedStatus, isRemoteNotFoundStatus, partialFlushDecision, remoteMessageKey, requeueRemoteEventsKeepingNewest, resolveRemoteMessageAction, + resolveZooRemoteEventSeq, workspaceNicknameFromFsPath, } from "../bridgeHelpers.js" @@ -48,5 +51,17 @@ describe("bridgeHelpers", () => { expect(isRemoteNotFoundStatus(404)).toBe(true) expect(isRemoteNotFoundStatus(500)).toBe(false) expect(isRemoteNotFoundStatus(undefined)).toBe(false) + expect(classifyRemoteControlHttpResult({ ok: false, status: 401 })).toBe("auth_blocked") + expect(classifyRemoteControlHttpResult({ ok: false, status: 404 })).toBe("unavailable") + expect(classifyRemoteControlHttpResult({ ok: true, status: 200 })).toBe("success") + expect(classifyRemoteControlHttpResult({ ok: false, status: 500 })).toBe("failed") + }) + + it("normalizes event seq and instance ids", () => { + expect(resolveZooRemoteEventSeq(undefined)).toBe(0) + expect(resolveZooRemoteEventSeq(-1)).toBe(0) + expect(resolveZooRemoteEventSeq(12)).toBe(12) + expect(generateZooRemoteInstanceId(1, "sess-1")).toBe("sess-1") + expect(generateZooRemoteInstanceId(1, undefined)).toMatch(/^zoo-1-/) }) }) diff --git a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts index 08ada12ed0..4f1d4d8ae3 100644 --- a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts @@ -334,4 +334,29 @@ describe("executeRemoteCommand", () => { expect(handleModeSwitch).toHaveBeenCalledWith("architect") expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) }) + + it("acks duplicate commandIds without re-running the host action", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const startNewTask = vi.fn().mockResolvedValue(undefined) + const handledCommandIds = new Set() + + await executeRemoteCommand({ + command: { commandId: "dup", type: "start_task", payload: { text: "first" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ startNewTask }), + handledCommandIds, + }) + await executeRemoteCommand({ + command: { commandId: "dup", type: "start_task", payload: { text: "again" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ startNewTask }), + handledCommandIds, + }) + + expect(startNewTask).toHaveBeenCalledTimes(1) + expect(ackCommand).toHaveBeenCalledTimes(2) + expect(ackCommand).toHaveBeenLastCalledWith({ commandId: "dup", status: "acked" }) + }) }) diff --git a/packages/core/src/zoo-remote-control/bridgeHelpers.ts b/packages/core/src/zoo-remote-control/bridgeHelpers.ts index f22795cb63..4b9516d9b8 100644 --- a/packages/core/src/zoo-remote-control/bridgeHelpers.ts +++ b/packages/core/src/zoo-remote-control/bridgeHelpers.ts @@ -69,3 +69,26 @@ export function isRemoteAuthBlockedStatus(status: number | undefined) { export function isRemoteNotFoundStatus(status: number | undefined) { return status === 404 } + +export function resolveZooRemoteEventSeq(existing: number | undefined) { + return typeof existing === "number" && Number.isFinite(existing) && existing >= 0 ? existing : 0 +} + +export function generateZooRemoteInstanceId(now: number, sessionId: string | undefined) { + if (typeof sessionId === "string" && sessionId.length > 0) { + return sessionId + } + return `zoo-${now}-${Math.random().toString(36).slice(2, 10)}` +} + +export type RemoteControlHttpKind = "auth_blocked" | "unavailable" | "success" | "failed" + +export function classifyRemoteControlHttpResult(result: { ok: boolean; status?: number }): RemoteControlHttpKind { + if (isRemoteAuthBlockedStatus(result.status)) { + return "auth_blocked" + } + if (isRemoteNotFoundStatus(result.status)) { + return "unavailable" + } + return result.ok ? "success" : "failed" +} diff --git a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts index 12688f025c..9ad63669f1 100644 --- a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts +++ b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts @@ -65,13 +65,22 @@ export async function executeRemoteCommand(options: { epoch: number client: Pick host: ZooRemoteCommandHost + /** Session-scoped at-most-once set (especially for start_task). */ + handledCommandIds?: Set }): Promise { - const { command, epoch, client, host } = options + const { command, epoch, client, host, handledCommandIds } = options if (!host.isLifecycleCurrent(epoch)) { return } + if (handledCommandIds?.has(command.commandId)) { + await client.ackCommand({ commandId: command.commandId, status: "acked" }) + return + } + // Claim before work so concurrent polls cannot double-apply start_task. + handledCommandIds?.add(command.commandId) + if (!host.isEligible()) { await failCommand(client, host, epoch, command.commandId, "not_eligible") return diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 6be87b5d25..c2605ddaef 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2400,14 +2400,14 @@ describe("webviewMessageHandler - remote control session", () => { expect(mockSetSessionActive).toHaveBeenNthCalledWith(2, false) }) - it("logs and completes when the bridge is unavailable", async () => { + it("no-ops when the bridge is unavailable", async () => { mockGetZooRemoteControlBridge.mockReturnValue(undefined) await expect( webviewMessageHandler(mockClineProvider, { type: "startRemoteControlSession" }), ).resolves.not.toThrow() expect(mockSetSessionActive).not.toHaveBeenCalled() - expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("bridge unavailable")) + expect(mockClineProvider.log).not.toHaveBeenCalled() }) it("selects the narrowest ConfigurationTarget for updateVSCodeSetting", async () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index d62b1d8ac9..420bc56407 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1980,12 +1980,7 @@ export const webviewMessageHandler = async ( case "endRemoteControlSession": { try { const { getZooRemoteControlBridge } = await import("../../extension") - const bridge = getZooRemoteControlBridge() - if (!bridge) { - provider.log("[ZooRemoteControl] session toggle ignored: bridge unavailable") - break - } - await bridge.setSessionActive(message.type === "startRemoteControlSession") + await getZooRemoteControlBridge()?.setSessionActive(message.type === "startRemoteControlSession") } catch (error) { provider.log( `[ZooRemoteControl] session toggle failed: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index 45d12c1c0c..b65710ee89 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -1,9 +1,9 @@ import * as vscode from "vscode" import { + classifyRemoteControlHttpResult, executeRemoteCommand, - isRemoteAuthBlockedStatus, - isRemoteNotFoundStatus, + generateZooRemoteInstanceId, isZooRemoteControlEligible, partialFlushDecision, projectClineMessageForRemote, @@ -11,6 +11,7 @@ import { remoteMessageKey, requeueRemoteEventsKeepingNewest, resolveRemoteMessageAction, + resolveZooRemoteEventSeq, setZooRemoteControlAvailable, workspaceNicknameFromFsPath, ZooRemoteControlClient, @@ -89,25 +90,17 @@ export class ZooRemoteControlBridge implements vscode.Disposable { void this.handleMessageEvent(payload) } - private readonly onTaskInteractive = (taskId: string) => { - this.enqueueEvent({ type: "task_interactive", taskId, timestamp: this.now() }) - } - - private readonly onTaskIdle = (taskId: string) => { - this.enqueueEvent({ type: "task_idle", taskId, timestamp: this.now() }) - } - - private readonly onTaskResumable = (taskId: string) => { - this.enqueueEvent({ type: "task_resumable", taskId, timestamp: this.now() }) - } - - private readonly onTaskCompleted = (taskId: string) => { - this.enqueueEvent({ type: "task_completed", taskId, timestamp: this.now() }) + private taskEventHandler(type: ZooRemoteEvent["type"]) { + return (taskId: string) => { + this.enqueueEvent({ type, taskId, timestamp: this.now() }) + } } - private readonly onTaskAborted = (taskId: string) => { - this.enqueueEvent({ type: "task_aborted", taskId, timestamp: this.now() }) - } + private readonly onTaskInteractive = this.taskEventHandler("task_interactive") + private readonly onTaskIdle = this.taskEventHandler("task_idle") + private readonly onTaskResumable = this.taskEventHandler("task_resumable") + private readonly onTaskCompleted = this.taskEventHandler("task_completed") + private readonly onTaskAborted = this.taskEventHandler("task_aborted") private readonly onModeSwitched = (taskId: string, mode: string) => { this.enqueueEvent({ type: "mode_switched", taskId, mode, timestamp: this.now() }) @@ -239,13 +232,10 @@ export class ZooRemoteControlBridge implements vscode.Disposable { if (existing) { return { id: existing, needsPersist: false } } - - const generated = - typeof vscode.env.sessionId === "string" && vscode.env.sessionId.length > 0 - ? vscode.env.sessionId - : `zoo-${this.now()}-${Math.random().toString(36).slice(2, 10)}` - - return { id: generated, needsPersist: true } + return { + id: generateZooRemoteInstanceId(this.now(), vscode.env.sessionId), + needsPersist: true, + } } private async persistInstanceId(id: string): Promise { @@ -261,8 +251,7 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private resolveEventSeq(): number { - const existing = this.context.globalState.get(EVENT_SEQ_STATE_KEY) - return typeof existing === "number" && Number.isFinite(existing) && existing >= 0 ? existing : 0 + return resolveZooRemoteEventSeq(this.context.globalState.get(EVENT_SEQ_STATE_KEY)) } private persistEventSeq(): void { @@ -376,17 +365,12 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private clearTimers(): void { - if (this.presenceTimer) { - clearInterval(this.presenceTimer) - this.presenceTimer = undefined - } - if (this.commandTimer) { - clearInterval(this.commandTimer) - this.commandTimer = undefined - } - if (this.flushTimer) { - clearInterval(this.flushTimer) - this.flushTimer = undefined + for (const key of ["presenceTimer", "commandTimer", "flushTimer"] as const) { + const timer = this[key] + if (timer) { + clearInterval(timer) + this[key] = undefined + } } } @@ -527,20 +511,19 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - if (isRemoteAuthBlockedStatus(result.status)) { + const kind = classifyRemoteControlHttpResult(result) + if (kind === "auth_blocked") { this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) if (this.active) { await this.deactivate({ notifyOffline: false }) } return } - - if (isRemoteNotFoundStatus(result.status)) { + if (kind === "unavailable") { this.markBackoff(UNAVAILABLE_BACKOFF_MS) return } - - if (result.ok) { + if (kind === "success") { this.setServerAvailable(true) } } @@ -573,17 +556,16 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - if (isRemoteAuthBlockedStatus(result.status)) { + const kind = classifyRemoteControlHttpResult(result) + if (kind === "auth_blocked") { this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) return } - - if (isRemoteNotFoundStatus(result.status)) { + if (kind === "unavailable") { this.markBackoff(UNAVAILABLE_BACKOFF_MS) return } - - if (result.ok) { + if (kind === "success") { this.setServerAvailable(true) } } @@ -605,25 +587,23 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - if (isRemoteAuthBlockedStatus(result.status)) { + const kind = classifyRemoteControlHttpResult(result) + if (kind === "auth_blocked") { this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) // Keep the failed batch ahead of anything enqueued during the await; cap from the // tail so the newest events survive under pressure. this.pendingEvents = requeueRemoteEventsKeepingNewest(batch, this.pendingEvents) return } - - if (isRemoteNotFoundStatus(result.status)) { + if (kind === "unavailable") { this.markBackoff(UNAVAILABLE_BACKOFF_MS) // Drop batch while the public endpoint is not available yet. return } - - if (!result.ok) { + if (kind === "failed") { this.pendingEvents = requeueRemoteEventsKeepingNewest(batch, this.pendingEvents) return } - this.setServerAvailable(true) } @@ -645,18 +625,17 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return } - if (isRemoteAuthBlockedStatus(result.status)) { + const kind = classifyRemoteControlHttpResult(result) + if (kind === "auth_blocked") { this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) await this.deactivate({ notifyOffline: false }) return } - - if (isRemoteNotFoundStatus(result.status)) { + if (kind === "unavailable") { this.markBackoff(UNAVAILABLE_BACKOFF_MS) return } - - if (!result.ok || !result.body) { + if (kind !== "success" || !result.body) { return } @@ -674,17 +653,11 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private async executeCommand(command: ZooRemoteCommand, epoch: number): Promise { - if (this.handledCommandIds.has(command.commandId)) { - if (this.isLifecycleCurrent(epoch)) { - await this.client.ackCommand({ commandId: command.commandId, status: "acked" }) - } - return - } - await executeRemoteCommand({ command, epoch, client: this.client, + handledCommandIds: this.handledCommandIds, host: { isLifecycleCurrent: (value) => this.isLifecycleCurrent(value), isEligible: () => this.evaluateEligible(), @@ -710,6 +683,5 @@ export class ZooRemoteControlBridge implements vscode.Disposable { endRemoteSession: () => this.setSessionActive(false), }, }) - this.handledCommandIds.add(command.commandId) } } From 6d94c8185302dab20854743e28ff45826d7f5c9a Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:19:55 -0600 Subject: [PATCH 26/36] fix: release unapplied remote commandId claims for retry Keep an in-flight claim against concurrent double-apply, but drop it on failure paths so redelivery is not falsely acked as success. --- .../__tests__/executeRemoteCommand.spec.ts | 27 +++ .../executeRemoteCommand.ts | 224 ++++++++++-------- 2 files changed, 149 insertions(+), 102 deletions(-) diff --git a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts index 4f1d4d8ae3..a25f9ab12c 100644 --- a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts @@ -359,4 +359,31 @@ describe("executeRemoteCommand", () => { expect(ackCommand).toHaveBeenCalledTimes(2) expect(ackCommand).toHaveBeenLastCalledWith({ commandId: "dup", status: "acked" }) }) + + it("releases a failed commandId so redelivery can retry", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const startNewTask = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce(undefined) + const handledCommandIds = new Set() + + await executeRemoteCommand({ + command: { commandId: "retry-me", type: "start_task", payload: { text: "go" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ startNewTask }), + handledCommandIds, + }) + expect(handledCommandIds.has("retry-me")).toBe(false) + + await executeRemoteCommand({ + command: { commandId: "retry-me", type: "start_task", payload: { text: "go" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ startNewTask }), + handledCommandIds, + }) + + expect(startNewTask).toHaveBeenCalledTimes(2) + expect(handledCommandIds.has("retry-me")).toBe(true) + expect(ackCommand).toHaveBeenLastCalledWith({ commandId: "retry-me", status: "acked" }) + }) }) diff --git a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts index 9ad63669f1..405ade8bb8 100644 --- a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts +++ b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts @@ -78,122 +78,142 @@ export async function executeRemoteCommand(options: { await client.ackCommand({ commandId: command.commandId, status: "acked" }) return } - // Claim before work so concurrent polls cannot double-apply start_task. + // Claim for the in-flight window so concurrent polls cannot double-apply. + // Release on every non-applied path so the server can redeliver after a real failure. handledCommandIds?.add(command.commandId) - - if (!host.isEligible()) { - await failCommand(client, host, epoch, command.commandId, "not_eligible") - return - } + let applied = false try { - switch (command.type) { - case "message": - case "answer_followup": - if (!(await ensureTaskTarget({ command, epoch, client, host }))) { - return - } - if (!host.isLifecycleCurrent(epoch)) { - return - } - await host.sendMessage(command.payload?.text, command.payload?.images) - if (command.payload?.mode) { + if (!host.isEligible()) { + await failCommand(client, host, epoch, command.commandId, "not_eligible") + return + } + + try { + switch (command.type) { + case "message": + case "answer_followup": + if (!(await ensureTaskTarget({ command, epoch, client, host }))) { + return + } if (!host.isLifecycleCurrent(epoch)) { return } - await host.handleModeSwitch(command.payload.mode) - } - break - case "approve_ask": - case "deny_ask": { - if (!(await ensureTaskTarget({ command, epoch, client, host }))) { - return - } - const task = host.getCurrentTask() - if (!task) { - await failCommand(client, host, epoch, command.commandId, "no_current_task") - return - } - if (!task.hasPendingAsk()) { - await failCommand(client, host, epoch, command.commandId, "no_pending_ask") - return - } - if (!host.isLifecycleCurrent(epoch)) { - return - } - if (command.type === "approve_ask") { - task.approveAsk({ text: command.payload?.text, images: command.payload?.images }) - } else { - task.denyAsk({ text: command.payload?.text, images: command.payload?.images }) - } - break - } - case "cancel_task": - if (!(await ensureTaskTarget({ command, epoch, client, host }))) { - return - } - if (!host.isLifecycleCurrent(epoch)) { - return - } - await host.cancelCurrentTask() - break - case "resume_task": - if (!command.taskId) { - await failCommand(client, host, epoch, command.commandId, "missing_task_id") - return - } - if (!host.isLifecycleCurrent(epoch)) { - return - } - await host.resumeTask(command.taskId) - break - case "start_task": - if (!host.isLifecycleCurrent(epoch)) { - return + await host.sendMessage(command.payload?.text, command.payload?.images) + if (command.payload?.mode) { + if (!host.isLifecycleCurrent(epoch)) { + // Message already landed — keep the claim. + applied = true + return + } + await host.handleModeSwitch(command.payload.mode) + } + applied = true + break + case "approve_ask": + case "deny_ask": { + if (!(await ensureTaskTarget({ command, epoch, client, host }))) { + return + } + const task = host.getCurrentTask() + if (!task) { + await failCommand(client, host, epoch, command.commandId, "no_current_task") + return + } + if (!task.hasPendingAsk()) { + await failCommand(client, host, epoch, command.commandId, "no_pending_ask") + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + if (command.type === "approve_ask") { + task.approveAsk({ text: command.payload?.text, images: command.payload?.images }) + } else { + task.denyAsk({ text: command.payload?.text, images: command.payload?.images }) + } + applied = true + break } - await host.startNewTask({ - text: command.payload?.text, - images: command.payload?.images, - }) - break - case "switch_mode": { - const mode = command.payload?.mode - if (!mode) { - await failCommand(client, host, epoch, command.commandId, "missing_mode") - return + case "cancel_task": + if (!(await ensureTaskTarget({ command, epoch, client, host }))) { + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.cancelCurrentTask() + applied = true + break + case "resume_task": + if (!command.taskId) { + await failCommand(client, host, epoch, command.commandId, "missing_task_id") + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.resumeTask(command.taskId) + applied = true + break + case "start_task": + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.startNewTask({ + text: command.payload?.text, + images: command.payload?.images, + }) + applied = true + break + case "switch_mode": { + const mode = command.payload?.mode + if (!mode) { + await failCommand(client, host, epoch, command.commandId, "missing_mode") + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.handleModeSwitch(mode) + applied = true + break } - if (!host.isLifecycleCurrent(epoch)) { + case "end_remote_session": + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.endRemoteSession() + applied = true + break + default: { + const _exhaustive: never = command.type + await failCommand(client, host, epoch, command.commandId, "unknown_command", String(_exhaustive)) return } - await host.handleModeSwitch(mode) - break } - case "end_remote_session": - if (!host.isLifecycleCurrent(epoch)) { - return - } - await host.endRemoteSession() - break - default: { - const _exhaustive: never = command.type - await failCommand(client, host, epoch, command.commandId, "unknown_command", String(_exhaustive)) + + if (!host.isLifecycleCurrent(epoch)) { return } + // Ack 404 (command_not_found) must not mark the bridge unavailable. + await client.ackCommand({ commandId: command.commandId, status: "acked" }) + } catch (error) { + // Keep the claim if the host action already landed; only failed attempts are retriable. + if (!applied) { + await failCommand( + client, + host, + epoch, + command.commandId, + "execution_failed", + error instanceof Error ? error.message : String(error), + ) + } } - - if (!host.isLifecycleCurrent(epoch)) { - return + } finally { + if (!applied) { + handledCommandIds?.delete(command.commandId) } - // Ack 404 (command_not_found) must not mark the bridge unavailable. - await client.ackCommand({ commandId: command.commandId, status: "acked" }) - } catch (error) { - await failCommand( - client, - host, - epoch, - command.commandId, - "execution_failed", - error instanceof Error ? error.message : String(error), - ) } } From f3ff16ef52b118af57b0c46ccf11747cbf73ca76 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:39:07 -0600 Subject: [PATCH 27/36] test: tighten remote commandId claim and failure ack coverage --- .../__tests__/executeRemoteCommand.spec.ts | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts index a25f9ab12c..62887738c1 100644 --- a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts @@ -337,17 +337,29 @@ describe("executeRemoteCommand", () => { it("acks duplicate commandIds without re-running the host action", async () => { const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) - const startNewTask = vi.fn().mockResolvedValue(undefined) + let markStarted: () => void = () => {} + const started = new Promise((resolve) => { + markStarted = resolve + }) + let releaseStart: () => void = () => {} + const canFinish = new Promise((resolve) => { + releaseStart = resolve + }) + const startNewTask = vi.fn(async () => { + markStarted() + await canFinish + }) const handledCommandIds = new Set() - await executeRemoteCommand({ + const firstExecution = executeRemoteCommand({ command: { commandId: "dup", type: "start_task", payload: { text: "first" } }, epoch: 1, client: { ackCommand }, host: makeHost({ startNewTask }), handledCommandIds, }) - await executeRemoteCommand({ + await started + const secondExecution = executeRemoteCommand({ command: { commandId: "dup", type: "start_task", payload: { text: "again" } }, epoch: 1, client: { ackCommand }, @@ -356,6 +368,8 @@ describe("executeRemoteCommand", () => { }) expect(startNewTask).toHaveBeenCalledTimes(1) + releaseStart() + await Promise.all([firstExecution, secondExecution]) expect(ackCommand).toHaveBeenCalledTimes(2) expect(ackCommand).toHaveBeenLastCalledWith({ commandId: "dup", status: "acked" }) }) @@ -373,6 +387,12 @@ describe("executeRemoteCommand", () => { handledCommandIds, }) expect(handledCommandIds.has("retry-me")).toBe(false) + expect(ackCommand).toHaveBeenLastCalledWith({ + commandId: "retry-me", + status: "failed", + errorCode: "execution_failed", + message: "boom", + }) await executeRemoteCommand({ command: { commandId: "retry-me", type: "start_task", payload: { text: "go" } }, From 0e70c76ad1975a25c5eba6d4b9311a84261068a6 Mon Sep 17 00:00:00 2001 From: JamesRobert20 <59908268+JamesRobert20@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:03:49 -0600 Subject: [PATCH 28/36] fix: bind remote commands to the live task and disclose transcript storage Approvals were landing on whatever ask was pending, and the privacy copy said task content is not stored. --- .gitignore | 1 - PRIVACY.md | 22 ++- .../__tests__/executeRemoteCommand.spec.ts | 138 ++++++++++++------ .../executeRemoteCommand.ts | 16 +- packages/core/src/zoo-remote-control/types.ts | 2 + .../ZooRemoteControlBridge.ts | 77 +++++++--- .../__tests__/ZooRemoteControlBridge.spec.ts | 89 +++++++++-- webview-ui/src/components/chat/TaskHeader.tsx | 4 +- webview-ui/src/i18n/locales/ca/chat.json | 2 +- webview-ui/src/i18n/locales/de/chat.json | 2 +- webview-ui/src/i18n/locales/en/chat.json | 2 +- webview-ui/src/i18n/locales/es/chat.json | 2 +- webview-ui/src/i18n/locales/fr/chat.json | 2 +- webview-ui/src/i18n/locales/hi/chat.json | 2 +- webview-ui/src/i18n/locales/id/chat.json | 2 +- webview-ui/src/i18n/locales/it/chat.json | 2 +- webview-ui/src/i18n/locales/ja/chat.json | 2 +- webview-ui/src/i18n/locales/ko/chat.json | 2 +- webview-ui/src/i18n/locales/nl/chat.json | 2 +- webview-ui/src/i18n/locales/pl/chat.json | 2 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 2 +- webview-ui/src/i18n/locales/ru/chat.json | 2 +- webview-ui/src/i18n/locales/tr/chat.json | 2 +- webview-ui/src/i18n/locales/vi/chat.json | 2 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 2 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 2 +- 26 files changed, 277 insertions(+), 108 deletions(-) diff --git a/.gitignore b/.gitignore index aab63b9ca3..831b269065 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,3 @@ roo-cli-*.tar.gz* # Local Cursor agent rules/settings (not shared) .cursor/ - diff --git a/PRIVACY.md b/PRIVACY.md index f43760023d..7f535e2c6b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Zoo Code Privacy Policy -**Last Updated: August 15th, 2026** +**Last Updated: October 21st, 2026** Zoo Code respects your privacy and is committed to transparency about how we handle your data. Below is a simple breakdown of where key pieces of data @@ -15,20 +15,30 @@ go—and, importantly, where they don't. provider (proxy mode), your code may transit Zoo Code servers only to forward it to the upstream provider. We do not store your code; it is deleted immediately after forwarding. Otherwise, your code is sent directly to the - provider. AI providers may store data per their privacy policies. + provider. AI providers may store data per their privacy policies. Remote + control, described below, is separate: that task transcript is stored for 7 + days. - **Commands**: Any commands executed through Zoo Code happen on your local environment. However, when you use AI-powered features, the relevant code and context from your commands may be transmitted to your chosen AI model provider - (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not have - access to or store this data, but AI providers may process it per their - privacy policies. + (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store + that model-request context, but AI providers may process it per their privacy + policies. Remote control, described below, is separate: that task transcript + is stored for 7 days. - **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service. If you choose Zoo Code Cloud as the provider (proxy mode), prompts may transit Zoo Code servers only to forward - them to the upstream model and are not stored. + them to the upstream model and are not stored. Remote control, described + below, is separate: that task transcript is stored for 7 days. +- **Remote control**: Turning on remote control is optional. When you do, Zoo + Code sends that task's conversation, the file contents included in the task, + and command output to your Zoo account. Zoo stores that transcript for 7 days + so you can view the task and approve, reply, or otherwise control it from the + Zoo dashboard, then deletes it. This storage is separate from model-provider + requests and from telemetry. Ending the remote session stops further uploads. - **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI model), it is stored locally on your device and never sent to us or any third party, except the provider you have chosen. diff --git a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts index 62887738c1..339909ccf2 100644 --- a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts +++ b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts @@ -1,7 +1,18 @@ import { describe, expect, it, vi } from "vitest" import { executeRemoteCommand } from "../executeRemoteCommand.js" -import type { ZooRemoteCommandHost } from "../executeRemoteCommand.js" +import type { ZooRemoteCommandHost, ZooRemoteCommandHostTask } from "../executeRemoteCommand.js" + +function liveTask(overrides: Partial = {}): ZooRemoteCommandHostTask { + return { + taskId: "t1", + hasPendingAsk: () => false, + pendingAskTs: () => undefined, + approveAsk: vi.fn(), + denyAsk: vi.fn(), + ...overrides, + } +} function makeHost(overrides: Partial = {}): ZooRemoteCommandHost { return { @@ -24,10 +35,10 @@ describe("executeRemoteCommand", () => { const sendMessage = vi.fn().mockResolvedValue(undefined) await executeRemoteCommand({ - command: { commandId: "c1", type: "message", payload: { text: "hi" } }, + command: { commandId: "c1", type: "message", taskId: "t1", payload: { text: "hi" } }, epoch: 1, client: { ackCommand }, - host: makeHost({ sendMessage }), + host: makeHost({ sendMessage, getCurrentTask: () => liveTask() }), }) expect(sendMessage).toHaveBeenCalledWith("hi", undefined) @@ -39,10 +50,10 @@ describe("executeRemoteCommand", () => { const handleModeSwitch = vi.fn().mockResolvedValue(undefined) await executeRemoteCommand({ - command: { commandId: "c1", type: "message", payload: { text: "hi", mode: "code" } }, + command: { commandId: "c1", type: "message", taskId: "t1", payload: { text: "hi", mode: "code" } }, epoch: 1, client: { ackCommand }, - host: makeHost({ handleModeSwitch }), + host: makeHost({ handleModeSwitch, getCurrentTask: () => liveTask() }), }) expect(handleModeSwitch).toHaveBeenCalledWith("code") @@ -84,20 +95,24 @@ describe("executeRemoteCommand", () => { }) }) - it("acks no_current_task when approve_ask has no task", async () => { + it("acks missing_task_id when approve_ask omits the task id", async () => { const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const approveAsk = vi.fn() await executeRemoteCommand({ - command: { commandId: "c1", type: "approve_ask" }, + command: { commandId: "c1", type: "approve_ask", payload: { askTs: 1 } }, epoch: 1, client: { ackCommand }, - host: makeHost(), + host: makeHost({ + getCurrentTask: () => liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 1, approveAsk }), + }), }) + expect(approveAsk).not.toHaveBeenCalled() expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "failed", - errorCode: "no_current_task", + errorCode: "missing_task_id", }) }) @@ -106,16 +121,11 @@ describe("executeRemoteCommand", () => { const approveAsk = vi.fn() await executeRemoteCommand({ - command: { commandId: "c1", type: "approve_ask" }, + command: { commandId: "c1", type: "approve_ask", taskId: "t1", payload: { askTs: 1 } }, epoch: 1, client: { ackCommand }, host: makeHost({ - getCurrentTask: () => ({ - taskId: "t1", - hasPendingAsk: () => false, - approveAsk, - denyAsk: vi.fn(), - }), + getCurrentTask: () => liveTask({ approveAsk }), }), }) @@ -136,12 +146,7 @@ describe("executeRemoteCommand", () => { epoch: 1, client: { ackCommand }, host: makeHost({ - getCurrentTask: () => ({ - taskId: "t1", - hasPendingAsk: () => false, - approveAsk: vi.fn(), - denyAsk: vi.fn(), - }), + getCurrentTask: () => liveTask(), cancelCurrentTask, }), }) @@ -207,11 +212,12 @@ describe("executeRemoteCommand", () => { const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) await executeRemoteCommand({ - command: { commandId: "c1", type: "message", payload: { text: "hi" } }, + command: { commandId: "c1", type: "message", taskId: "t1", payload: { text: "hi" } }, epoch: 1, client: { ackCommand }, host: makeHost({ sendMessage: vi.fn().mockRejectedValue(new Error("boom")), + getCurrentTask: () => liveTask(), }), }) @@ -228,16 +234,11 @@ describe("executeRemoteCommand", () => { const approveAsk = vi.fn() await executeRemoteCommand({ - command: { commandId: "c1", type: "approve_ask", payload: { text: "ok" } }, + command: { commandId: "c1", type: "approve_ask", taskId: "t1", payload: { text: "ok", askTs: 42 } }, epoch: 1, client: { ackCommand }, host: makeHost({ - getCurrentTask: () => ({ - taskId: "t1", - hasPendingAsk: () => true, - approveAsk, - denyAsk: vi.fn(), - }), + getCurrentTask: () => liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 42, approveAsk }), }), }) @@ -250,16 +251,11 @@ describe("executeRemoteCommand", () => { const denyAsk = vi.fn() await executeRemoteCommand({ - command: { commandId: "c1", type: "deny_ask", payload: { text: "no" } }, + command: { commandId: "c1", type: "deny_ask", taskId: "t1", payload: { text: "no", askTs: 42 } }, epoch: 1, client: { ackCommand }, host: makeHost({ - getCurrentTask: () => ({ - taskId: "t1", - hasPendingAsk: () => true, - approveAsk: vi.fn(), - denyAsk, - }), + getCurrentTask: () => liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 42, denyAsk }), }), }) @@ -276,12 +272,7 @@ describe("executeRemoteCommand", () => { epoch: 1, client: { ackCommand }, host: makeHost({ - getCurrentTask: () => ({ - taskId: "t1", - hasPendingAsk: () => false, - approveAsk: vi.fn(), - denyAsk: vi.fn(), - }), + getCurrentTask: () => liveTask(), cancelCurrentTask, }), }) @@ -406,4 +397,65 @@ describe("executeRemoteCommand", () => { expect(handledCommandIds.has("retry-me")).toBe(true) expect(ackCommand).toHaveBeenLastCalledWith({ commandId: "retry-me", status: "acked" }) }) + + it("acks missing_task_id for a message that does not name a task", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const sendMessage = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", payload: { text: "hi" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ sendMessage, getCurrentTask: () => liveTask() }), + }) + + expect(sendMessage).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "missing_task_id", + }) + }) + + it("acks missing_ask_id when approve_ask omits the ask timestamp", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const approveAsk = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "approve_ask", taskId: "t1", payload: { text: "ok" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 42, approveAsk }), + }), + }) + + expect(approveAsk).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "missing_ask_id", + }) + }) + + it("acks ask_mismatch when approve_ask names a different ask", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const approveAsk = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "approve_ask", taskId: "t1", payload: { askTs: 7 } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 42, approveAsk }), + }), + }) + + expect(approveAsk).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "ask_mismatch", + }) + }) }) diff --git a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts index 405ade8bb8..49466504ae 100644 --- a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts +++ b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts @@ -4,6 +4,8 @@ import type { ZooRemoteControlClient } from "./ZooRemoteControlClient.js" export type ZooRemoteCommandHostTask = { taskId: string hasPendingAsk: () => boolean + /** Timestamp of the ask currently blocking the task, when one is pending. */ + pendingAskTs: () => number | undefined approveAsk: (payload?: { text?: string; images?: string[] }) => void denyAsk: (payload?: { text?: string; images?: string[] }) => void } @@ -40,7 +42,7 @@ async function failCommand( }) } -/** When a command targets a task id, require it to match the live task. */ +/** Task-mutating commands must name the live task. A missing id is not "current task". */ async function ensureTaskTarget(options: { command: ZooRemoteCommand epoch: number @@ -49,7 +51,8 @@ async function ensureTaskTarget(options: { }): Promise { const { command, epoch, client, host } = options if (!command.taskId) { - return true + await failCommand(client, host, epoch, command.commandId, "missing_task_id") + return false } const currentId = host.getCurrentTask()?.taskId if (currentId === command.taskId) { @@ -124,6 +127,15 @@ export async function executeRemoteCommand(options: { await failCommand(client, host, epoch, command.commandId, "no_pending_ask") return } + const askTs = command.payload?.askTs + if (askTs === undefined) { + await failCommand(client, host, epoch, command.commandId, "missing_ask_id") + return + } + if (task.pendingAskTs() !== askTs) { + await failCommand(client, host, epoch, command.commandId, "ask_mismatch") + return + } if (!host.isLifecycleCurrent(epoch)) { return } diff --git a/packages/core/src/zoo-remote-control/types.ts b/packages/core/src/zoo-remote-control/types.ts index 47e25d8ccd..cba107c7d8 100644 --- a/packages/core/src/zoo-remote-control/types.ts +++ b/packages/core/src/zoo-remote-control/types.ts @@ -30,6 +30,8 @@ export const zooRemoteCommandSchema = z.object({ text: z.string().optional(), images: z.array(z.string()).optional(), mode: z.string().optional(), + /** Timestamp of the ask the user approved or denied. */ + askTs: z.number().optional(), }) .optional(), timestamp: z.number().optional(), diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts index b65710ee89..9cb01aec6a 100644 --- a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -77,6 +77,10 @@ export class ZooRemoteControlBridge implements vscode.Disposable { private eventSeqPersistFailed = false /** Bumped on deactivate/dispose so in-flight polls/flushes become no-ops. */ private lifecycleEpoch = 0 + /** Serializes eligibility sync so activate and deactivate cannot overlap. */ + private syncChain: Promise = Promise.resolve() + /** Set before the identity await so a second activate cannot pass the first check. */ + private activating = false private commandPollInFlight = false /** At-most-once delivery for remote commands (esp. start_task) within an active session. */ private handledCommandIds = new Set() @@ -293,6 +297,21 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private async syncActiveState(): Promise { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const previous = this.syncChain + this.syncChain = gate + await previous + try { + await this.applyEligibility() + } finally { + release() + } + } + + private async applyEligibility(): Promise { if (this.disposed) { return } @@ -312,32 +331,39 @@ export class ZooRemoteControlBridge implements vscode.Disposable { } private async activate(): Promise { - if (this.active || this.disposed) { + if (this.active || this.activating || this.disposed) { return } - const identityOk = await this.identityReady - if (!identityOk || this.disposed) { - this.log("[ZooRemoteControl] refusing to activate: instance id is not persisted") - return - } + this.activating = true + try { + const identityOk = await this.identityReady + if (!identityOk || this.disposed || this.active) { + if (!identityOk && !this.disposed) { + this.log("[ZooRemoteControl] refusing to activate: instance id is not persisted") + } + return + } - this.active = true - this.setServerAvailable(true) - this.bindApiListeners(true) - this.presenceTimer = setInterval(() => { - void this.publishPresence(true) - }, PRESENCE_INTERVAL_MS) - this.commandTimer = setInterval(() => { - void this.pollCommands() - }, COMMAND_POLL_INTERVAL_MS) - this.flushTimer = setInterval(() => { - void this.flushEvents() - }, EVENT_FLUSH_INTERVAL_MS) - - this.log("[ZooRemoteControl] enabled") - await this.publishPresence(true) - await this.publishSnapshot() + this.active = true + this.setServerAvailable(true) + this.bindApiListeners(true) + this.presenceTimer = setInterval(() => { + void this.publishPresence(true) + }, PRESENCE_INTERVAL_MS) + this.commandTimer = setInterval(() => { + void this.pollCommands() + }, COMMAND_POLL_INTERVAL_MS) + this.flushTimer = setInterval(() => { + void this.flushEvents() + }, EVENT_FLUSH_INTERVAL_MS) + + this.log("[ZooRemoteControl] enabled") + await this.publishPresence(true) + await this.publishSnapshot() + } finally { + this.activating = false + } } private async deactivate({ notifyOffline }: { notifyOffline: boolean }): Promise { @@ -671,6 +697,13 @@ export class ZooRemoteControlBridge implements vscode.Disposable { return { taskId: task.taskId, hasPendingAsk: () => task.hasPendingAsk(), + pendingAskTs: () => { + if (!task.hasPendingAsk()) { + return undefined + } + const last = task.clineMessages.at(-1) + return typeof last?.ts === "number" ? last.ts : undefined + }, approveAsk: (payload) => task.approveAsk(payload), denyAsk: (payload) => task.denyAsk(payload), } diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts index 439d755a67..d378f54153 100644 --- a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -125,6 +125,21 @@ describe("ZooRemoteControlBridge", () => { }) } + function useLiveTask(options?: { taskId?: string; pendingAskTs?: number }) { + const task = { + taskId: options?.taskId ?? "task-1", + clineMessages: + options?.pendingAskTs === undefined + ? [] + : [{ ts: options.pendingAskTs, type: "ask", ask: "command", partial: false }], + hasPendingAsk: () => options?.pendingAskTs !== undefined, + approveAsk: vi.fn(), + denyAsk: vi.fn(), + } + provider.getCurrentTask.mockReturnValue(task) + return task + } + it("stays inactive when remote control is disabled", async () => { const bridge = createBridge() await vi.advanceTimersByTimeAsync(0) @@ -184,6 +199,47 @@ describe("ZooRemoteControlBridge", () => { ] }) + it("does not leave a duplicate listener when eligibility drops during activation", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + context.globalState.get.mockImplementation((key: string) => { + if (key === "zooRemoteControl.eventSeq") return 0 + if (key === "zooRemoteControl.sessionActive") return true + return undefined + }) + let releaseId: () => void = () => {} + const idGate = new Promise((resolve) => { + releaseId = resolve + }) + context.globalState.update.mockImplementation(async (key: string, value: unknown) => { + if (key === "zooRemoteControl.instanceId") { + await idGate + } + if (key === "zooRemoteControl.sessionActive") { + sessionActive = value === true + } + }) + + const bridge = createBridge() + await Promise.resolve() + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.anthropic, + }) + provider.emit(RooCodeEventName.ProviderProfileChanged, { + name: "Anthropic", + provider: providerIdentifiers.anthropic, + }) + releaseId() + await vi.advanceTimersByTimeAsync(0) + + expect(bridge.isActive()).toBe(false) + expect(api.listenerCount(RooCodeEventName.Message)).toBe(0) + bridge.dispose() + }) + it("does not activate when the provider is not eligible", async () => { sessionActive = true vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") @@ -204,6 +260,7 @@ describe("ZooRemoteControlBridge", () => { provider.contextProxy.getProviderSettings.mockReturnValue({ apiProvider: providerIdentifiers.zooGateway, }) + useLiveTask() client.getCommands.mockResolvedValue({ ok: true, status: 200, @@ -212,6 +269,7 @@ describe("ZooRemoteControlBridge", () => { { commandId: "cmd-1", type: "message", + taskId: "task-1", payload: { text: "hello from remote" }, }, ], @@ -316,20 +374,14 @@ describe("ZooRemoteControlBridge", () => { } it("approves and denies asks on the current task", async () => { - const task = { - taskId: "task-1", - hasPendingAsk: vi.fn(() => true), - approveAsk: vi.fn(), - denyAsk: vi.fn(), - } - provider.getCurrentTask.mockReturnValue(task) + const task = useLiveTask({ pendingAskTs: 42 }) client.getCommands.mockResolvedValueOnce({ ok: true, status: 200, body: { commands: [ - { commandId: "a1", type: "approve_ask", payload: { text: "ok" } }, - { commandId: "d1", type: "deny_ask", payload: { text: "no" } }, + { commandId: "a1", type: "approve_ask", taskId: "task-1", payload: { text: "ok", askTs: 42 } }, + { commandId: "d1", type: "deny_ask", taskId: "task-1", payload: { text: "no", askTs: 42 } }, ], }, }) @@ -344,7 +396,7 @@ describe("ZooRemoteControlBridge", () => { bridge.dispose() }) - it("fails ask commands when there is no current task", async () => { + it("fails ask commands that do not name a task", async () => { provider.getCurrentTask.mockReturnValue(undefined) client.getCommands.mockResolvedValueOnce({ ok: true, @@ -363,12 +415,12 @@ describe("ZooRemoteControlBridge", () => { expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "a1", status: "failed", - errorCode: "no_current_task", + errorCode: "missing_task_id", }) expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "d1", status: "failed", - errorCode: "no_current_task", + errorCode: "missing_task_id", }) bridge.dispose() }) @@ -397,13 +449,14 @@ describe("ZooRemoteControlBridge", () => { }) it("cancels, resumes, starts, and switches mode from remote commands", async () => { + useLiveTask() provider.handleModeSwitch = vi.fn().mockResolvedValue(undefined) client.getCommands.mockResolvedValueOnce({ ok: true, status: 200, body: { commands: [ - { commandId: "c1", type: "cancel_task" }, + { commandId: "c1", type: "cancel_task", taskId: "task-1" }, { commandId: "r1", type: "resume_task", taskId: "task-9" }, { commandId: "s1", type: "start_task", payload: { text: "new work" } }, { commandId: "m1", type: "switch_mode", payload: { mode: "code" } }, @@ -471,11 +524,12 @@ describe("ZooRemoteControlBridge", () => { }) it("acks execution_failed when a command throws", async () => { + useLiveTask() api.sendMessage.mockRejectedValueOnce(new Error("boom")) client.getCommands.mockResolvedValueOnce({ ok: true, status: 200, - body: { commands: [{ commandId: "m1", type: "message", payload: { text: "x" } }] }, + body: { commands: [{ commandId: "m1", type: "message", taskId: "task-1", payload: { text: "x" } }] }, }) const bridge = await activateEligibleBridge() @@ -544,11 +598,14 @@ describe("ZooRemoteControlBridge", () => { }) it("sends message commands to the local API", async () => { + useLiveTask() client.getCommands.mockResolvedValueOnce({ ok: true, status: 200, body: { - commands: [{ commandId: "m1", type: "message", payload: { text: "hello", images: ["img"] } }], + commands: [ + { commandId: "m1", type: "message", taskId: "task-1", payload: { text: "hello", images: ["img"] } }, + ], }, }) @@ -561,6 +618,7 @@ describe("ZooRemoteControlBridge", () => { }) it("answers follow-ups and can switch mode from the payload", async () => { + useLiveTask() provider.handleModeSwitch = vi.fn().mockResolvedValue(undefined) client.getCommands.mockResolvedValueOnce({ ok: true, @@ -570,6 +628,7 @@ describe("ZooRemoteControlBridge", () => { { commandId: "f1", type: "answer_followup", + taskId: "task-1", payload: { text: "option a", mode: "architect" }, }, ], diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index de186ed6e6..fc5911695e 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -219,7 +219,9 @@ const TaskHeader = ({ className="mb-2 flex items-center justify-between gap-2 rounded border border-vscode-dropdown-border bg-vscode-editor-background p-2 text-sm" onClick={(e) => e.stopPropagation()} data-testid="remote-steer-offer"> - {t("chat:task.steerRemotelyHint")} + + {t("chat:task.steerRemotelyHint")} +