diff --git a/apps/cli/src/commands/schedule.test.ts b/apps/cli/src/commands/schedule.test.ts index 3a1db71525..db1fed0804 100644 --- a/apps/cli/src/commands/schedule.test.ts +++ b/apps/cli/src/commands/schedule.test.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createScheduleCommand } from "./schedule"; -const mockSendHubCommand = vi.hoisted(() => vi.fn()); +const mockHubClientCommand = vi.hoisted(() => vi.fn()); +const mockNodeHubClientCtor = vi.hoisted(() => vi.fn()); const mockEnsureCliHubServer = vi.hoisted(() => vi.fn()); const mockProviderSettings = vi.hoisted(() => ({ lastUsed: undefined as { provider?: string; model?: string } | undefined, @@ -16,7 +17,17 @@ vi.mock("@cline/core", async () => { await vi.importActual("@cline/core"); return { ...actual, - sendHubCommand: mockSendHubCommand, + NodeHubClient: class { + command = mockHubClientCommand; + + constructor(options: Record) { + mockNodeHubClientCtor(options); + } + + async connect(): Promise {} + + close(): void {} + }, ProviderSettingsManager: class { getLastUsedProviderSettings() { return mockProviderSettings.lastUsed; @@ -74,7 +85,7 @@ describe("runScheduleCommand list output", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedules: [] }, }); @@ -96,18 +107,21 @@ describe("runScheduleCommand list output", () => { expect(code).toBe(0); expect(errors).toEqual([]); expect(output).toEqual(["No schedules found."]); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, - { - clientId: "cline-schedule", - command: "schedule.list", - payload: { - limit: 100, - enabled: undefined, - tags: undefined, - }, - }, + // Schedule commands are workspace-scoped: the hub client must register + // with a workspace context (and the hub auth token) before commanding. + expect(mockNodeHubClientCtor).toHaveBeenCalledWith( + expect.objectContaining({ + url: "ws://127.0.0.1:25463/hub", + workspaceRoot: process.cwd(), + cwd: process.cwd(), + authToken: "test-token", + }), ); + expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.list", { + limit: 100, + enabled: undefined, + tags: undefined, + }); }); it("keeps JSON list output unchanged when --json is provided", async () => { @@ -115,7 +129,7 @@ describe("runScheduleCommand list output", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedules: [] }, }); @@ -137,7 +151,7 @@ describe("runScheduleCommand list output", () => { expect(code).toBe(0); expect(errors).toEqual([]); expect(output).toEqual(["[]"]); - expect(mockSendHubCommand).toHaveBeenCalled(); + expect(mockHubClientCommand).toHaveBeenCalled(); }); }); @@ -157,7 +171,7 @@ describe("runScheduleCommand create", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: { scheduleId: "sched_123" } }, }); @@ -189,15 +203,19 @@ describe("runScheduleCommand create", () => { expect(code).toBe(0); expect(errors).toEqual([]); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, + expect(mockNodeHubClientCtor).toHaveBeenCalledWith( expect.objectContaining({ - clientId: "cline-schedule", - command: "schedule.create", - payload: expect.objectContaining({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }), + url: "ws://127.0.0.1:25463/hub", + workspaceRoot: "/tmp/workspace", + cwd: "/tmp/workspace", + authToken: "test-token", + }), + ); + expect(mockHubClientCommand).toHaveBeenCalledWith( + "schedule.create", + expect.objectContaining({ + provider: "anthropic", + model: "claude-sonnet-4-6", }), ); }); @@ -215,7 +233,7 @@ describe("runScheduleCommand create", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: { scheduleId: "sched_123" } }, }); @@ -246,14 +264,11 @@ describe("runScheduleCommand create", () => { expect(code).toBe(0); expect(errors).toEqual([]); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, + expect(mockHubClientCommand).toHaveBeenCalledWith( + "schedule.create", expect.objectContaining({ - command: "schedule.create", - payload: expect.objectContaining({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }), + provider: "anthropic", + model: "claude-sonnet-4-6", }), ); }); @@ -292,7 +307,7 @@ describe("runScheduleCommand create", () => { expect(errors).toEqual([ 'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.', ]); - expect(mockSendHubCommand).not.toHaveBeenCalled(); + expect(mockHubClientCommand).not.toHaveBeenCalled(); }); it("maps --delivery-bot to delivery.userName", async () => { @@ -300,7 +315,7 @@ describe("runScheduleCommand create", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: { scheduleId: "sched_delivery" } }, }); @@ -339,21 +354,17 @@ describe("runScheduleCommand create", () => { expect(code).toBe(0); expect(errors).toEqual([]); expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, - { - clientId: "cline-schedule", - command: "schedule.create", - payload: expect.objectContaining({ - metadata: { - delivery: { - adapter: "telegram", - threadId: "telegram:123456789", - userName: "my_bot", - }, + expect(mockHubClientCommand).toHaveBeenCalledWith( + "schedule.create", + expect.objectContaining({ + metadata: { + delivery: { + adapter: "telegram", + threadId: "telegram:123456789", + userName: "my_bot", }, - }), - }, + }, + }), ); }); }); @@ -370,7 +381,7 @@ describe("runScheduleCommand import", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: { scheduleId: "sched_123" } }, }); @@ -411,16 +422,12 @@ describe("runScheduleCommand import", () => { expect(code).toBe(0); expect(errors).toEqual([]); expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, - { - clientId: "cline-schedule", - command: "schedule.create", - payload: expect.objectContaining({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }), - }, + expect(mockHubClientCommand).toHaveBeenCalledWith( + "schedule.create", + expect.objectContaining({ + provider: "anthropic", + model: "claude-sonnet-4-6", + }), ); }); }); @@ -444,7 +451,7 @@ describe("runScheduleCommand export", () => { prompt: "review status", workspaceRoot: "/tmp/workspace", }; - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: scheduleRecord }, }); @@ -484,14 +491,9 @@ describe("runScheduleCommand export", () => { const written = await readFile(targetPath, "utf8"); expect(written).toBe(JSON.stringify(scheduleRecord, null, 2)); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, - { - clientId: "cline-schedule", - command: "schedule.get", - payload: { scheduleId: "sched_abc" }, - }, - ); + expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.get", { + scheduleId: "sched_abc", + }); } finally { await rm(targetPath, { force: true }); } @@ -507,7 +509,7 @@ describe("runScheduleCommand export", () => { name: "Weekly Sync", cronPattern: "0 9 * * 1", }; - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: scheduleRecord }, }); diff --git a/apps/cli/src/commands/schedule/client.ts b/apps/cli/src/commands/schedule/client.ts index 23da54e897..b45e857981 100644 --- a/apps/cli/src/commands/schedule/client.ts +++ b/apps/cli/src/commands/schedule/client.ts @@ -2,7 +2,7 @@ import { createLocalHubScheduleRuntimeHandlers, HubScheduleCommandService, HubScheduleService, - sendHubCommand, + NodeHubClient, } from "@cline/core"; import { ensureCliHubServer, @@ -11,28 +11,51 @@ import { import type { CommandIo } from "./types"; export class HubScheduleClient { + private hub: Promise | undefined; + constructor( - private readonly endpoint: { - host?: string; - port?: number; - pathname?: string; - }, + private readonly url: string, + private readonly workspaceRoot: string, + private readonly authToken?: string, ) {} - close(): void {} + close(): void { + const hub = this.hub; + this.hub = undefined; + void hub?.then((client) => client.close()).catch(() => undefined); + } + + // Schedule commands are authorized against the workspace bound to the + // connection's client registration, so all commands must share one + // registered connection instead of fire-and-forget envelopes. + private connectedHub(): Promise { + this.hub ??= (async () => { + const client = new NodeHubClient({ + url: this.url, + clientType: "cli-schedule", + displayName: "Cline CLI scheduler", + workspaceRoot: this.workspaceRoot, + cwd: this.workspaceRoot, + authToken: this.authToken, + }); + try { + await client.connect(); + } catch (error) { + client.close(); + this.hub = undefined; + throw error; + } + return client; + })(); + return this.hub; + } private async command( command: string, payload?: Record, ): Promise> { - const reply = await sendHubCommand(this.endpoint, { - clientId: "cline-schedule", - command: command as never, - payload, - }); - if (!reply.ok) { - throw new Error(reply.error?.message ?? `hub command failed: ${command}`); - } + const client = await this.connectedHub(); + const reply = await client.command(command as never, payload); return (reply.payload ?? {}) as Record; } @@ -97,6 +120,7 @@ export class LocalScheduleClient { runtimeHandlers: createLocalHubScheduleRuntimeHandlers(), }); private readonly commands = new HubScheduleCommandService(this.service); + constructor(private readonly workspaceRoot: string) {} close(): void { void this.service.dispose(); @@ -106,12 +130,21 @@ export class LocalScheduleClient { command: string, payload?: Record, ): Promise> { - const reply = await this.commands.handleCommand({ - version: "v1", - clientId: "cline-schedule-local", - command: command as never, - payload, - }); + const reply = await this.commands.handleCommand( + { + version: "v1", + clientId: "cline-schedule-local", + command: command as never, + payload, + }, + { + clientId: "cline-schedule-local", + workspaceContext: { + workspaceRoot: this.workspaceRoot, + cwd: this.workspaceRoot, + }, + }, + ); if (!reply.ok) { throw new Error(reply.error?.message ?? `hub command failed: ${command}`); } @@ -185,24 +218,27 @@ export async function ensureSchedulerHub( if (!address?.trim()) { return { ok: true, - client: new LocalScheduleClient() as unknown as HubScheduleClient, + client: new LocalScheduleClient( + workspaceRoot, + ) as unknown as HubScheduleClient, }; } try { const requestedEndpoint = parseHubEndpointOverride(address); - const { url: hubUrl } = await ensureCliHubServer( + const { url: hubUrl, authToken } = await ensureCliHubServer( workspaceRoot, requestedEndpoint, ); - const endpoint = parseHubEndpointOverride(hubUrl); return { ok: true, - client: new HubScheduleClient(endpoint), + client: new HubScheduleClient(hubUrl, workspaceRoot, authToken), }; } catch (_error) { return { ok: true, - client: new LocalScheduleClient() as unknown as HubScheduleClient, + client: new LocalScheduleClient( + workspaceRoot, + ) as unknown as HubScheduleClient, }; } } diff --git a/apps/cline-hub/src/server/desktop-commands.ts b/apps/cline-hub/src/server/desktop-commands.ts index b5573df821..7354f94bbf 100644 --- a/apps/cline-hub/src/server/desktop-commands.ts +++ b/apps/cline-hub/src/server/desktop-commands.ts @@ -253,7 +253,7 @@ export async function handleDesktopCommand( return path; } if (ROUTINE_SCHEDULE_COMMANDS.has(command)) { - return await handleRoutineScheduleCommand(command, args); + return await handleRoutineScheduleCommand(command, args, workspaceRoot); } if (command === "get_process_context") { return { workspaceRoot, cwd: workspaceRoot }; diff --git a/apps/cline-hub/src/server/schedules.ts b/apps/cline-hub/src/server/schedules.ts index b32ec1bec9..03c4d9ca7b 100644 --- a/apps/cline-hub/src/server/schedules.ts +++ b/apps/cline-hub/src/server/schedules.ts @@ -27,13 +27,20 @@ function getCommands(): HubScheduleCommandService { async function clientCommand( hubCommand: string, payload?: Record, + workspaceRoot = process.cwd(), ): Promise> { - const reply = await getCommands().handleCommand({ - version: "v1", - clientId: "cline-hub-schedules", - command: hubCommand as never, - payload, - }); + const reply = await getCommands().handleCommand( + { + version: "v1", + clientId: "cline-hub-schedules", + command: hubCommand as never, + payload, + }, + { + clientId: "cline-hub-schedules", + workspaceContext: { workspaceRoot, cwd: workspaceRoot }, + }, + ); if (!reply.ok) { throw new Error( reply.error?.message ?? `hub command failed: ${hubCommand}`, @@ -70,14 +77,17 @@ function asTrimmedStringArray(value: unknown): string[] | undefined { export async function handleRoutineScheduleCommand( command: string, args?: Record, + workspaceRoot = process.cwd(), ): Promise { + const commandHub = (hubCommand: string, payload?: Record) => + clientCommand(hubCommand, payload, workspaceRoot); if (command === "list_routine_schedules") { const [schedules, activeExecutions, upcomingRuns] = await Promise.all([ - clientCommand("schedule.list", { + commandHub("schedule.list", { limit: toPositiveInt(args?.limit) ?? 200, }), - clientCommand("schedule.active"), - clientCommand("schedule.upcoming", { limit: 30 }), + commandHub("schedule.active"), + commandHub("schedule.upcoming", { limit: 30 }), ]); const scheduleRows = Array.isArray(schedules.schedules) ? schedules.schedules @@ -88,7 +98,7 @@ export async function handleRoutineScheduleCommand( (schedule as Record).scheduleId, ); if (!scheduleId) return undefined; - const reply = await clientCommand("schedule.list_executions", { + const reply = await commandHub("schedule.list_executions", { scheduleId, limit: 1, }); @@ -115,7 +125,7 @@ export async function handleRoutineScheduleCommand( "createSchedule requires name, timing, prompt, and workspace_root", ); } - const created = await clientCommand("schedule.create", { + const created = await commandHub("schedule.create", { name, ...timing, prompt, @@ -149,7 +159,7 @@ export async function handleRoutineScheduleCommand( "updateSchedule requires schedule_id, name, timing, prompt, and workspace_root", ); } - const reply = await clientCommand("schedule.update", { + const reply = await commandHub("schedule.update", { scheduleId, name, ...timing, @@ -180,25 +190,25 @@ export async function handleRoutineScheduleCommand( return { schedule: reply.schedule ?? null }; } if (command === "pause_routine_schedule") { - const reply = await clientCommand("schedule.disable", { scheduleId }); + const reply = await commandHub("schedule.disable", { scheduleId }); return { schedule: reply.schedule ?? null }; } if (command === "resume_routine_schedule") { - const reply = await clientCommand("schedule.enable", { scheduleId }); + const reply = await commandHub("schedule.enable", { scheduleId }); return { schedule: reply.schedule ?? null }; } if (command === "trigger_routine_schedule") { - const existing = await clientCommand("schedule.get", { scheduleId }); + const existing = await commandHub("schedule.get", { scheduleId }); if (!existing.schedule) throw new Error(`schedule not found: ${scheduleId}`); - const reply = await clientCommand("schedule.trigger", { + const reply = await commandHub("schedule.trigger", { scheduleId, wait: false, }); return { execution: reply.execution ?? null }; } if (command === "delete_routine_schedule") { - const reply = await clientCommand("schedule.delete", { scheduleId }); + const reply = await commandHub("schedule.delete", { scheduleId }); return { deleted: reply.deleted === true }; } throw new Error(`unsupported routine schedule command: ${command}`); diff --git a/apps/examples/desktop-app/README.md b/apps/examples/desktop-app/README.md index b6eeadd218..0673714bee 100644 --- a/apps/examples/desktop-app/README.md +++ b/apps/examples/desktop-app/README.md @@ -6,8 +6,9 @@ Tauri desktop shell + Bun sidecar backend + Next.js UI for running and inspectin From `apps/examples/desktop-app/`: -- `bun run dev:web` - Next.js UI only (`http://localhost:3125`) -- `bun run dev:sidecar` - sidecar backend only +- `bun run dev:headless` - Next.js UI (`http://localhost:3125`) and sidecar backend with a fresh shared approval credential +- `bun run dev:web` - Next.js UI only (approval-gated tools require `dev:headless` or the native app) +- `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app) - `bun run dev` - Tauri desktop dev - `bun run build` - build web assets - `bun run build:sidecar` - build the Bun sidecar bundle diff --git a/apps/examples/desktop-app/package.json b/apps/examples/desktop-app/package.json index 9e0878eeb5..1279705da6 100644 --- a/apps/examples/desktop-app/package.json +++ b/apps/examples/desktop-app/package.json @@ -7,6 +7,8 @@ "predev:web": "bun run build:ui", "dev:web": "next dev webview -p 3125 --turbo", "dev:sidecar": "bun run sidecar/index.ts", + "predev:headless": "bun run build:ui", + "dev:headless": "bun run scripts/dev-headless.ts", "dev": "tauri dev --config src-tauri/tauri.dev.conf.json", "prebuild": "bun run build:ui", "build": "bun run bun.mts", diff --git a/apps/examples/desktop-app/scripts/dev-headless.ts b/apps/examples/desktop-app/scripts/dev-headless.ts new file mode 100644 index 0000000000..3598798bf2 --- /dev/null +++ b/apps/examples/desktop-app/scripts/dev-headless.ts @@ -0,0 +1,64 @@ +import { randomUUID } from "node:crypto"; +import { createServer } from "node:net"; + +const approvalToken = randomUUID(); +const children: ReturnType[] = []; + +async function reserveAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to reserve a sidecar port")); + return; + } + server.close((error) => + error ? reject(error) : resolve(address.port), + ); + }); + }); +} + +function spawn(command: string[], env: Record) { + const child = Bun.spawn(command, { + cwd: import.meta.dir + "/..", + env: { ...process.env, ...env }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + children.push(child); + return child; +} + +function stopChildren(): void { + for (const child of children) { + if (!child.killed) child.kill(); + } +} + +process.on("SIGINT", stopChildren); +process.on("SIGTERM", stopChildren); + +async function main(): Promise { + const sidecarPort = await reserveAvailablePort(); + const endpoint = `ws://127.0.0.1:${sidecarPort}/transport?approval_token=${approvalToken}`; + const sidecar = spawn(["bun", "run", "sidecar/index.ts"], { + CLINE_SIDECAR_APPROVAL_TOKEN: approvalToken, + CLINE_SIDECAR_PORT: String(sidecarPort), + }); + const web = spawn( + ["bun", "run", "next", "dev", "webview", "-p", "3125", "--turbo"], + { NEXT_PUBLIC_SIDECAR_WS_ENDPOINT: endpoint }, + ); + + const exitCode = await Promise.race([sidecar.exited, web.exited]); + stopChildren(); + await Promise.allSettled(children.map((child) => child.exited)); + process.exit(exitCode); +} + +void main(); diff --git a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md index 45099d4d0e..d59bfe622e 100644 --- a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md +++ b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md @@ -175,7 +175,8 @@ Supported commands: ## Dev Workflow ```bash -bun run dev:sidecar # Start sidecar on port 3126 -bun run dev:web # Start Next.js on port 3125 +bun run dev:headless # Start sidecar and Next.js with a fresh shared approval credential +bun run dev:sidecar # Start only the sidecar (no browser approval surface) +bun run dev:web # Start only Next.js (no authenticated approval connection) bun run dev # Both concurrently ``` diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index 1df7e4c775..ba20e49bd9 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -67,6 +67,7 @@ import { broadcastEvent, ensureSharedHubClient, resolveSidecarAskQuestion, + sendEventToClient, } from "./context"; import { installMarketplaceEntryForDesktopCommand, @@ -107,6 +108,7 @@ import type { ChatSessionCommandRequest, JsonRecord, SidecarContext, + SidecarWebSocketClient, } from "./types"; import { pickWorkspaceDirectory } from "./workspace-picker"; @@ -741,6 +743,43 @@ async function handleRoutineScheduleCommand( throw new Error(`unsupported routine schedule command: ${command}`); } +// --------------------------------------------------------------------------- +// Agenda task queue helpers (in-process via shared hub server) +// --------------------------------------------------------------------------- + +const AGENDA_TASK_COMMANDS = new Set([ + "task.create", + "task.list", + "task.get", + "task.update", + "task.approve", + "task.cancel", + "task.run", + "task.automation.get", + "task.automation.set", +]); + +const AGENDA_TASK_EXECUTION_COMMANDS = new Set([ + "task.create", + "task.approve", + "task.cancel", + "task.run", + "task.automation.set", +]); + +async function handleAgendaTaskCommand( + ctx: SidecarContext, + command: string, + args?: Record, +): Promise { + const hubClient = await ensureSharedHubClient(ctx); + const reply = await hubClient.command(command as never, args); + if (!reply.ok) { + throw new Error(reply.error?.message ?? `hub command failed: ${command}`); + } + return reply.payload ?? {}; +} + // --------------------------------------------------------------------------- // User instruction config listing through the core config service. // --------------------------------------------------------------------------- @@ -1149,7 +1188,7 @@ export async function handleCommand( ctx: SidecarContext, command: string, args?: Record, - options?: { connection?: object }, + options?: { connection?: SidecarWebSocketClient }, ): Promise { // ── Chat session commands ────────────────────────────────────────── if (command === "chat_session_command") { @@ -1239,8 +1278,12 @@ export async function handleCommand( // ── Tool approvals (in-memory) ──────────────────────────────────── if (command === "poll_tool_approvals") { const sessionId = String(args?.sessionId ?? "").trim(); + const connection = options?.connection; + if (!connection?.data?.canApproveTools) { + throw new Error("tool approvals require a trusted desktop connection"); + } return Array.from(ctx.pendingApprovals.values()) - .filter((a) => a.item.sessionId === sessionId) + .filter((a) => a.owner === connection && a.item.sessionId === sessionId) .map((a) => a.item); } if (command === "respond_tool_approval") { @@ -1249,20 +1292,28 @@ export async function handleCommand( if (!sessionId || !requestId) { throw new Error("sessionId and requestId are required"); } - const pending = ctx.pendingApprovals.get(requestId); - if (pending) { - pending.resolve({ - approved: Boolean(args?.approved), - ...(typeof args?.reason === "string" && args.reason.trim().length > 0 - ? { reason: args.reason.trim() } - : {}), - }); + const connection = options?.connection; + if (!connection?.data?.canApproveTools) { + throw new Error("tool approvals require a trusted desktop connection"); } + const pending = ctx.pendingApprovals.get(requestId); + if (!pending || pending.owner !== connection) { + throw new Error("tool approval does not belong to this connection"); + } + if (pending.item.sessionId !== sessionId) { + throw new Error("tool approval does not belong to this session"); + } + pending.resolve({ + approved: Boolean(args?.approved), + ...(typeof args?.reason === "string" && args.reason.trim().length > 0 + ? { reason: args.reason.trim() } + : {}), + }); ctx.pendingApprovals.delete(requestId); const remaining = Array.from(ctx.pendingApprovals.values()) - .filter((a) => a.item.sessionId === sessionId) + .filter((a) => a.owner === connection && a.item.sessionId === sessionId) .map((a) => a.item); - broadcastEvent(ctx, "tool_approval_state", { + sendEventToClient(ctx, connection, "tool_approval_state", { sessionId, items: remaining, }); @@ -2025,6 +2076,17 @@ export async function handleCommand( return await handleRoutineScheduleCommand(ctx, command, args); } + // ── Agenda task queue ───────────────────────────────────────────── + if (AGENDA_TASK_COMMANDS.has(command)) { + if ( + AGENDA_TASK_EXECUTION_COMMANDS.has(command) && + !options?.connection?.data?.canApproveTools + ) { + throw new Error("task execution requires a trusted desktop connection"); + } + return await handleAgendaTaskCommand(ctx, command, args); + } + // ── User instruction configs ────────────────────────────────────── if (command === "list_user_instruction_configs") { return await listUserInstructionConfigs(ctx); diff --git a/apps/examples/desktop-app/sidecar/context.test.ts b/apps/examples/desktop-app/sidecar/context.test.ts index fd3ddb71e1..7f3e220e7c 100644 --- a/apps/examples/desktop-app/sidecar/context.test.ts +++ b/apps/examples/desktop-app/sidecar/context.test.ts @@ -15,6 +15,11 @@ const hubGetUrlMock = vi.hoisted(() => vi.fn()); const hubIsConnectedMock = vi.hoisted(() => vi.fn()); const nodeHubClientCtorMock = vi.hoisted(() => vi.fn()); const subscribeMock = vi.hoisted(() => vi.fn()); +const updateCapabilitiesMock = vi.hoisted(() => vi.fn()); + +vi.mock("@ai-sdk/provider-utils", () => ({ + createProviderDefinedToolFactory: vi.fn(() => vi.fn()), +})); vi.mock("@cline/core", async () => { const actual = @@ -35,6 +40,7 @@ vi.mock("@cline/core", async () => { getUrl = hubGetUrlMock; isConnected = hubIsConnectedMock; subscribe = subscribeMock; + updateCapabilities = updateCapabilitiesMock; dispose = vi.fn(); }, }; @@ -64,6 +70,7 @@ describe("Code sidecar runtime capabilities", () => { hubIsConnectedMock.mockReset(); nodeHubClientCtorMock.mockReset(); subscribeMock.mockReset(); + updateCapabilitiesMock.mockReset(); connectMock.mockResolvedValue(undefined); ensureCompatibleLocalHubUrlMock.mockResolvedValue( "ws://127.0.0.1:25463/hub", @@ -73,6 +80,7 @@ describe("Code sidecar runtime capabilities", () => { hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub"); hubIsConnectedMock.mockReturnValue(true); subscribeMock.mockReturnValue(() => {}); + updateCapabilitiesMock.mockResolvedValue(undefined); createCoreMock.mockResolvedValue({ runtimeAddress: "ws://127.0.0.1:25463/hub", subscribe: vi.fn(() => () => {}), @@ -459,7 +467,11 @@ describe("Code sidecar runtime capabilities", () => { const { handleCommand } = await import("./commands"); const ctx = createSidecarContext("/workspace/project"); - ctx.wsClients.add({ send: vi.fn() }); + const approvalClient = { + data: { canApproveTools: true }, + send: vi.fn(), + }; + ctx.wsClients.add(approvalClient); await initializeSessionManager(ctx); @@ -565,7 +577,11 @@ describe("Code sidecar runtime capabilities", () => { const { handleCommand } = await import("./commands"); const ctx = createSidecarContext("/workspace/project"); - ctx.wsClients.add({ send: vi.fn() }); + const approvalClient = { + data: { canApproveTools: true }, + send: vi.fn(), + }; + ctx.wsClients.add(approvalClient); await initializeSessionManager(ctx); @@ -597,9 +613,12 @@ describe("Code sidecar runtime capabilities", () => { }); expect(approval).toBeInstanceOf(Promise); - const pending = await handleCommand(ctx, "poll_tool_approvals", { - sessionId: "sess-1", - }); + const pending = await handleCommand( + ctx, + "poll_tool_approvals", + { sessionId: "sess-1" }, + { connection: approvalClient }, + ); expect(pending).toEqual([ expect.objectContaining({ sessionId: "sess-1", @@ -625,18 +644,270 @@ describe("Code sidecar runtime capabilities", () => { ); const [{ requestId }] = pending as Array<{ requestId: string }>; - await handleCommand(ctx, "respond_tool_approval", { - sessionId: "sess-1", - requestId, - approved: true, - }); + const untrustedClient = { send: vi.fn() }; + ctx.wsClients.add(untrustedClient); + await expect( + handleCommand( + ctx, + "respond_tool_approval", + { sessionId: "sess-1", requestId, approved: true }, + { connection: untrustedClient }, + ), + ).rejects.toThrow("trusted desktop connection"); + expect(ctx.pendingApprovals.size).toBe(1); + await handleCommand( + ctx, + "respond_tool_approval", + { sessionId: "sess-1", requestId, approved: true }, + { connection: approvalClient }, + ); await expect(approval).resolves.toEqual({ approved: true }); expect( - await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }), + await handleCommand( + ctx, + "poll_tool_approvals", + { sessionId: "sess-1" }, + { connection: approvalClient }, + ), ).toEqual([]); }); + it("rejects and removes an approval when initial delivery fails", async () => { + const { createSidecarContext, createSidecarRuntimeCapabilities } = + await import("./context"); + const ctx = createSidecarContext("/workspace/project"); + const failedClient = { + data: { canApproveTools: true }, + send: vi.fn(() => { + throw new Error("socket closed"); + }), + }; + ctx.wsClients.add(failedClient); + + const approval = createSidecarRuntimeCapabilities( + ctx, + ).requestToolApproval?.({ + sessionId: "sess-1", + agentId: "agent-1", + conversationId: "conversation-1", + iteration: 1, + toolCallId: "tool-call-1", + toolName: "run_commands", + input: { commands: ["echo hi"] }, + policy: { autoApprove: false }, + }); + + await expect(approval).resolves.toEqual({ + approved: false, + reason: "Desktop approval surface disconnected", + }); + expect(ctx.pendingApprovals.size).toBe(0); + expect(ctx.wsClients.has(failedClient)).toBe(false); + }); + + it("rejects an owned approval when a later broadcast fails", async () => { + const { + broadcastEvent, + createSidecarContext, + createSidecarRuntimeCapabilities, + } = await import("./context"); + const ctx = createSidecarContext("/workspace/project"); + const approvalClient = { + data: { canApproveTools: true }, + send: vi + .fn() + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error("socket closed"); + }), + }; + ctx.wsClients.add(approvalClient); + + const approval = createSidecarRuntimeCapabilities( + ctx, + ).requestToolApproval?.({ + sessionId: "sess-1", + agentId: "agent-1", + conversationId: "conversation-1", + iteration: 1, + toolCallId: "tool-call-1", + toolName: "run_commands", + input: { commands: ["echo hi"] }, + policy: { autoApprove: false }, + }); + expect(ctx.pendingApprovals.size).toBe(1); + + broadcastEvent(ctx, "task.updated", { taskId: "task-1" }); + + await expect(approval).resolves.toEqual({ + approved: false, + reason: "Desktop approval surface disconnected", + }); + expect(ctx.pendingApprovals.size).toBe(0); + expect(ctx.wsClients.has(approvalClient)).toBe(false); + }); + + it("rejects sibling approvals when a targeted state update fails", async () => { + const { createSidecarContext, createSidecarRuntimeCapabilities } = + await import("./context"); + const { handleCommand } = await import("./commands"); + const ctx = createSidecarContext("/workspace/project"); + const approvalClient = { + data: { canApproveTools: true }, + send: vi + .fn() + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error("socket closed"); + }), + }; + ctx.wsClients.add(approvalClient); + const capabilities = createSidecarRuntimeCapabilities(ctx); + const request = (toolCallId: string) => + capabilities.requestToolApproval?.({ + sessionId: "sess-1", + agentId: "agent-1", + conversationId: "conversation-1", + iteration: 1, + toolCallId, + toolName: "run_commands", + input: { commands: ["echo hi"] }, + policy: { autoApprove: false }, + }); + const firstApproval = request("tool-call-1"); + const siblingApproval = request("tool-call-2"); + const [{ requestId }] = (await handleCommand( + ctx, + "poll_tool_approvals", + { sessionId: "sess-1" }, + { connection: approvalClient }, + )) as Array<{ requestId: string }>; + + await handleCommand( + ctx, + "respond_tool_approval", + { sessionId: "sess-1", requestId, approved: true }, + { connection: approvalClient }, + ); + + await expect(firstApproval).resolves.toEqual({ approved: true }); + await expect(siblingApproval).resolves.toEqual({ + approved: false, + reason: "Desktop approval surface disconnected", + }); + expect(ctx.pendingApprovals.size).toBe(0); + expect(ctx.wsClients.has(approvalClient)).toBe(false); + }); + + it("serializes approval readiness updates and publishes the latest state", async () => { + const { + createSidecarContext, + initializeSessionManager, + syncSidecarApprovalReadiness, + } = await import("./context"); + const ctx = createSidecarContext("/workspace/project"); + await initializeSessionManager(ctx); + updateCapabilitiesMock.mockReset(); + + let finishDisconnectedUpdate: (() => void) | undefined; + updateCapabilitiesMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishDisconnectedUpdate = resolve; + }), + ) + .mockResolvedValue(undefined); + + const disconnected = syncSidecarApprovalReadiness(ctx); + await vi.waitFor(() => + expect(updateCapabilitiesMock).toHaveBeenCalledWith([]), + ); + ctx.wsClients.add({ + data: { canApproveTools: true }, + send: vi.fn(), + }); + const connected = syncSidecarApprovalReadiness(ctx); + expect(updateCapabilitiesMock).toHaveBeenCalledTimes(1); + + finishDisconnectedUpdate?.(); + await Promise.all([disconnected, connected]); + expect(updateCapabilitiesMock).toHaveBeenLastCalledWith([ + expect.objectContaining({ name: "approval.respond" }), + ]); + }); + + it("forwards Hub-owned task session approvals to the live desktop", async () => { + const { createSidecarContext, initializeSessionManager } = await import( + "./context" + ); + const { handleCommand } = await import("./commands"); + let onHubEvent: ((event: Record) => void) | undefined; + subscribeMock.mockImplementation((handler) => { + onHubEvent = handler; + return () => {}; + }); + const ctx = createSidecarContext("/workspace/project"); + const approvalClient = { + data: { canApproveTools: true }, + send: vi.fn(), + }; + ctx.wsClients.add(approvalClient); + await initializeSessionManager(ctx); + + expect(updateCapabilitiesMock).toHaveBeenCalledWith([ + expect.objectContaining({ name: "approval.respond" }), + ]); + onHubEvent?.({ + event: "approval.requested", + sessionId: "task-session-1", + payload: { + approvalId: "hub-approval-1", + agendaTaskId: "task-1", + agentId: "task-agent-1", + conversationId: "task-conversation-1", + iteration: 2, + toolCallId: "tool-call-1", + toolName: "write_to_file", + inputJson: JSON.stringify({ path: "src/a.ts" }), + policy: { autoApprove: false }, + }, + }); + await vi.waitFor(() => expect(ctx.pendingApprovals.size).toBe(1)); + const pendingItems = (await handleCommand( + ctx, + "poll_tool_approvals", + { sessionId: "task-session-1" }, + { connection: approvalClient }, + )) as Array<{ requestId: string }>; + const pending = pendingItems[0]; + if (!pending) throw new Error("expected a pending task approval"); + await handleCommand( + ctx, + "respond_tool_approval", + { + sessionId: "task-session-1", + requestId: pending.requestId, + approved: true, + }, + { connection: approvalClient }, + ); + + await vi.waitFor(() => + expect(hubCommandMock).toHaveBeenCalledWith( + "approval.respond", + { + approvalId: "hub-approval-1", + approved: true, + reason: undefined, + }, + "task-session-1", + ), + ); + }); + it("routes routine commands through the connected shared Hub client", async () => { const { createSidecarContext, initializeSessionManager } = await import( "./context" @@ -661,6 +932,115 @@ describe("Code sidecar runtime capabilities", () => { scheduleId: "schedule-1", }); }); + + it("proxies Agenda task commands through the connected shared Hub", async () => { + const { createSidecarContext, initializeSessionManager } = await import( + "./context" + ); + const { handleCommand } = await import("./commands"); + const task = { + taskId: "task-1", + title: "Review the PR", + status: "pending_approval", + }; + hubCommandMock.mockResolvedValue({ + ok: true, + payload: { tasks: [task] }, + }); + + const ctx = createSidecarContext("/workspace/project"); + await initializeSessionManager(ctx); + const approvalClient = { + data: { canApproveTools: true }, + send: vi.fn(), + }; + + await expect( + handleCommand(ctx, "task.list", { + workspaceRoot: "/workspace/project", + statuses: ["pending_approval"], + }), + ).resolves.toEqual({ tasks: [task] }); + expect(hubCommandMock).toHaveBeenCalledWith("task.list", { + workspaceRoot: "/workspace/project", + statuses: ["pending_approval"], + }); + + hubCommandMock.mockResolvedValueOnce({ + ok: true, + payload: { task: { ...task, status: "in_progress", revision: 4 } }, + }); + await expect( + handleCommand( + ctx, + "task.run", + { + taskId: "task-1", + expectedRevision: 4, + }, + { connection: approvalClient }, + ), + ).resolves.toEqual({ + task: { ...task, status: "in_progress", revision: 4 }, + }); + expect(hubCommandMock).toHaveBeenCalledWith("task.run", { + taskId: "task-1", + expectedRevision: 4, + }); + }); + + it.each([ + "task.create", + "task.approve", + "task.cancel", + "task.run", + "task.automation.set", + ])("rejects untrusted %s commands before they reach the shared Hub", async (command) => { + const { createSidecarContext, initializeSessionManager } = await import( + "./context" + ); + const { handleCommand } = await import("./commands"); + const ctx = createSidecarContext("/workspace/project"); + await initializeSessionManager(ctx); + const untrustedClient = { + data: { canApproveTools: false }, + send: vi.fn(), + }; + + await expect( + handleCommand(ctx, command, {}, { connection: untrustedClient }), + ).rejects.toThrow("task execution requires a trusted desktop connection"); + expect(hubCommandMock).not.toHaveBeenCalled(); + }); + + it("forwards Hub task events that do not have a session", async () => { + const { createSidecarContext, handleHubLiveEvent } = await import( + "./context" + ); + const ctx = createSidecarContext("/workspace/project"); + ctx.wsClients.add({ send: vi.fn() } as never); + + handleHubLiveEvent(ctx, { + event: "task.created", + payload: { + taskId: "task-1", + status: "pending_approval", + }, + }); + + expect(readEvents(ctx)).toEqual([ + { + type: "event", + event: { + name: "task.created", + payload: { + taskId: "task-1", + status: "pending_approval", + }, + }, + }, + ]); + }); }); describe("disposeSidecarContext attachment cleanup", () => { diff --git a/apps/examples/desktop-app/sidecar/context.ts b/apps/examples/desktop-app/sidecar/context.ts index 74540c9a52..20697402d7 100644 --- a/apps/examples/desktop-app/sidecar/context.ts +++ b/apps/examples/desktop-app/sidecar/context.ts @@ -15,7 +15,11 @@ import { type ToolApprovalRequest, type ToolApprovalResult, } from "@cline/core"; -import { type AgentEvent, isGeneratedMedia } from "@cline/shared"; +import { + type AgentEvent, + HUB_CLIENT_TOOL_APPROVAL_CAPABILITY, + isGeneratedMedia, +} from "@cline/shared"; import { discardAllTrackedAttachments, flushConsumedAttachments, @@ -29,6 +33,7 @@ import type { PendingToolApproval, PromptInQueue, SidecarContext, + SidecarWebSocketClient, } from "./types"; const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000; @@ -36,6 +41,7 @@ const hubClientInitialization = new WeakMap< SidecarContext, Promise >(); +const approvalReadinessUpdates = new WeakMap>(); // --------------------------------------------------------------------------- // Helpers — WebSocket broadcast @@ -59,10 +65,76 @@ function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void { client.send(encoded); } catch { ctx.wsClients.delete(client); + cancelSidecarToolApprovalsForOwner(ctx, client); + void syncSidecarApprovalReadiness(ctx).catch((error) => + ctx.logger?.error?.("Hub approval readiness update failed", { error }), + ); } } } +export function sendEventToClient( + ctx: SidecarContext, + client: SidecarWebSocketClient, + name: string, + payload: unknown, +): boolean { + try { + client.send(encodeSidecarEvent(name, payload)); + return true; + } catch { + ctx.wsClients.delete(client); + cancelSidecarToolApprovalsForOwner(ctx, client); + void syncSidecarApprovalReadiness(ctx).catch((error) => + ctx.logger?.error?.("Hub approval readiness update failed", { error }), + ); + return false; + } +} + +export function cancelSidecarToolApprovalsForOwner( + ctx: SidecarContext, + owner: SidecarWebSocketClient, +): void { + for (const [requestId, pending] of ctx.pendingApprovals) { + if (pending.owner !== owner) continue; + ctx.pendingApprovals.delete(requestId); + pending.resolve({ + approved: false, + reason: "Desktop approval surface disconnected", + }); + } +} + +export function syncSidecarApprovalReadiness( + ctx: SidecarContext, +): Promise { + const previous = approvalReadinessUpdates.get(ctx) ?? Promise.resolve(); + const update = previous.catch(() => undefined).then(async () => { + const hubClient = ctx.hubClient; + if (!hubClient) return; + await hubClient.updateCapabilities( + [...ctx.wsClients].some( + (client) => client.data?.canApproveTools === true, + ) + ? [ + { + name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY, + description: + "Cline Code has a live user surface for tool review.", + }, + ] + : [], + ); + }); + approvalReadinessUpdates.set(ctx, update); + return update.finally(() => { + if (approvalReadinessUpdates.get(ctx) === update) { + approvalReadinessUpdates.delete(ctx); + } + }); +} + // Session log appends are chained per session so writes stay ordered, but // they run asynchronously: a synchronous write per streamed token would stall // the sidecar event loop (and therefore every pending UI command) under load. @@ -658,6 +730,15 @@ function requestSidecarToolApproval( ctx: SidecarContext, request: ToolApprovalRequest, ): Promise { + const owner = [...ctx.wsClients].find( + (client) => client.data?.canApproveTools === true, + ); + if (!owner) { + return Promise.resolve({ + approved: false, + reason: "No trusted desktop approval surface is connected", + }); + } return new Promise((resolve) => { const requestId = randomUUID(); const pending: PendingToolApproval = { @@ -672,16 +753,25 @@ function requestSidecarToolApproval( agentId: request.agentId, conversationId: request.conversationId, }, + owner, resolve, }; ctx.pendingApprovals.set(requestId, pending); const sessionApprovals = Array.from(ctx.pendingApprovals.values()) - .filter((approval) => approval.item.sessionId === request.sessionId) + .filter( + (approval) => + approval.owner === owner && + approval.item.sessionId === request.sessionId, + ) .map((approval) => approval.item); - sendEvent(ctx, "tool_approval_state", { - sessionId: request.sessionId, - items: sessionApprovals, - }); + if ( + !sendEventToClient(ctx, owner, "tool_approval_state", { + sessionId: request.sessionId, + items: sessionApprovals, + }) + ) { + cancelSidecarToolApprovalsForOwner(ctx, owner); + } }); } @@ -693,6 +783,25 @@ export function handleHubLiveEvent( payload?: Record; }, ): void { + if (event.event === "approval.requested") { + if (typeof event.payload?.agendaTaskId !== "string") return; + void handleHubApprovalRequest(ctx, event).catch((error) => { + ctx.logger?.error?.("Hub task approval forwarding failed", { error }); + }); + return; + } + // Task lifecycle events are Hub-wide invalidations and usually do not have a + // session yet (pending and approved tasks explicitly predate their session). + // Forward them before the session-only live-chat projection below so Agenda + // surfaces stay current without polling. + if (event.event.startsWith("task.")) { + sendEvent(ctx, event.event, { + ...(event.payload ?? {}), + ...(event.sessionId ? { sessionId: event.sessionId } : {}), + }); + return; + } + const sessionId = typeof event.sessionId === "string" ? event.sessionId : ""; if (!sessionId) { return; @@ -836,6 +945,72 @@ export function handleHubLiveEvent( } } +async function handleHubApprovalRequest( + ctx: SidecarContext, + event: { + sessionId?: string; + payload?: Record; + }, +): Promise { + const sessionId = event.sessionId?.trim() || ""; + const approvalId = + typeof event.payload?.approvalId === "string" + ? event.payload.approvalId.trim() + : ""; + const toolCallId = + typeof event.payload?.toolCallId === "string" + ? event.payload.toolCallId.trim() + : ""; + const toolName = + typeof event.payload?.toolName === "string" + ? event.payload.toolName.trim() + : ""; + if (!sessionId || !approvalId || !toolCallId || !toolName) return; + let input: unknown; + try { + input = + typeof event.payload?.inputJson === "string" + ? JSON.parse(event.payload.inputJson) + : undefined; + } catch { + input = undefined; + } + const result = await requestSidecarToolApproval(ctx, { + sessionId, + agentId: + typeof event.payload?.agentId === "string" ? event.payload.agentId : "", + conversationId: + typeof event.payload?.conversationId === "string" + ? event.payload.conversationId + : sessionId, + iteration: + typeof event.payload?.iteration === "number" + ? event.payload.iteration + : 0, + toolCallId, + toolName, + input, + policy: + event.payload?.policy && + typeof event.payload.policy === "object" && + !Array.isArray(event.payload.policy) + ? (event.payload.policy as ToolApprovalRequest["policy"]) + : { autoApprove: false }, + }); + const client = ctx.hubClient; + if (!client) + throw new Error("Hub client disconnected before approval response"); + await client.command( + "approval.respond", + { + approvalId, + approved: result.approved, + reason: result.reason, + }, + sessionId, + ); +} + export async function initializeSessionManager( ctx: SidecarContext, ): Promise { @@ -909,6 +1084,7 @@ export async function ensureSharedHubClient( handleHubLiveEvent(ctx, event); }); ctx.hubClient = client; + await syncSidecarApprovalReadiness(ctx); return client; } catch (error) { await client.dispose().catch(() => undefined); diff --git a/apps/examples/desktop-app/sidecar/index.ts b/apps/examples/desktop-app/sidecar/index.ts index 5ae3017a24..24270f62be 100644 --- a/apps/examples/desktop-app/sidecar/index.ts +++ b/apps/examples/desktop-app/sidecar/index.ts @@ -128,7 +128,7 @@ async function main() { void shutdown("code_sidecar_before_exit"); }); - const { port } = startServer(ctx, SIDECAR_PORT, shutdown); + const { port, approvalToken } = startServer(ctx, SIDECAR_PORT, shutdown); observability.logger.log("Desktop sidecar ready", { port, mode: SIDECAR_MODE, @@ -152,12 +152,13 @@ async function main() { // A wildcard bind isn't a dialable address; advertise loopback instead. const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST; const endpoint = `http://${dialHost}:${port}`; - const wsEndpoint = `ws://${dialHost}:${port}/transport`; + const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`); + wsEndpoint.searchParams.set("approval_token", approvalToken); process.stdout.write( `${JSON.stringify({ type: "ready", endpoint, - wsEndpoint, + wsEndpoint: wsEndpoint.toString(), pid: process.pid, mode: SIDECAR_MODE, })}\n`, diff --git a/apps/examples/desktop-app/sidecar/server.test.ts b/apps/examples/desktop-app/sidecar/server.test.ts index bd28db8b87..1bff4a8a23 100644 --- a/apps/examples/desktop-app/sidecar/server.test.ts +++ b/apps/examples/desktop-app/sidecar/server.test.ts @@ -6,6 +6,8 @@ import { import { createFetchHandler, createWebSocketHandler } from "./server"; import type { SidecarContext } from "./types"; +const TEST_APPROVAL_TOKEN = "test-approval-token"; + function createTestServer() { return { port: 3126, @@ -14,7 +16,11 @@ function createTestServer() { } function createHandler(onShutdown = vi.fn()) { - return createFetchHandler({} as SidecarContext, onShutdown); + return createFetchHandler( + {} as SidecarContext, + onShutdown, + TEST_APPROVAL_TOKEN, + ); } function createTelemetryHandler(capture = vi.fn()) { @@ -85,6 +91,37 @@ describe("sidecar HTTP origin checks", () => { expect(server.upgrade).not.toHaveBeenCalled(); }); + it("does not grant approval authority to originless local clients", async () => { + const server = createTestServer(); + await createHandler()( + new Request( + `http://127.0.0.1:3126/transport?approval_token=${TEST_APPROVAL_TOKEN}`, + ), + server, + ); + + expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), { + data: { canApproveTools: false }, + }); + }); + + it("grants approval authority to the trusted desktop webview", async () => { + const server = createTestServer(); + await createHandler()( + new Request( + `http://127.0.0.1:3126/transport?approval_token=${TEST_APPROVAL_TOKEN}`, + { + headers: { origin: "tauri://localhost" }, + }, + ), + server, + ); + + expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), { + data: { canApproveTools: true }, + }); + }); + it("allows desktop webview origins in preflight responses", async () => { const server = createTestServer(); const response = await createHandler()( @@ -103,6 +140,20 @@ describe("sidecar HTTP origin checks", () => { "tauri://localhost", ); }); + + it("does not grant approval authority to a spoofed trusted origin", async () => { + const server = createTestServer(); + await createHandler()( + new Request("http://127.0.0.1:3126/transport", { + headers: { origin: "tauri://localhost" }, + }), + server, + ); + + expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), { + data: { canApproveTools: false }, + }); + }); }); describe("desktop error telemetry", () => { diff --git a/apps/examples/desktop-app/sidecar/server.ts b/apps/examples/desktop-app/sidecar/server.ts index 71c0c9a12a..f98f3631da 100644 --- a/apps/examples/desktop-app/sidecar/server.ts +++ b/apps/examples/desktop-app/sidecar/server.ts @@ -1,8 +1,14 @@ +import { randomUUID, timingSafeEqual } from "node:crypto"; import { captureSdkError } from "@cline/shared"; import type { DesktopTransportRequest } from "../webview/lib/desktop-transport"; import { MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES } from "../webview/lib/voice-input-limits"; import { handleCommand } from "./commands"; -import { encodeSidecarEvent, sendEvent } from "./context"; +import { + cancelSidecarToolApprovalsForOwner, + encodeSidecarEvent, + sendEvent, + syncSidecarApprovalReadiness, +} from "./context"; import { fetchMarketplaceCatalog } from "./marketplace"; import { cancelMcpOAuthAuthorizationsForOwner } from "./mcp-oauth"; import { cancelProviderOAuthLoginsForOwner } from "./oauth-login"; @@ -17,7 +23,10 @@ import { type SidecarServer = { port: number; - upgrade(req: Request): boolean; + upgrade( + req: Request, + options?: { data?: { canApproveTools?: boolean } }, + ): boolean; }; // Comma-separated extra origins (e.g. a dev server on a nonstandard port when @@ -40,6 +49,19 @@ const JSON_HEADERS = { "content-type": "application/json", }; +const APPROVAL_TOKEN_QUERY_PARAM = "approval_token"; + +function hasValidApprovalToken(url: URL, expectedToken: string): boolean { + const candidate = url.searchParams.get(APPROVAL_TOKEN_QUERY_PARAM); + if (!candidate) return false; + const candidateBytes = Buffer.from(candidate); + const expectedBytes = Buffer.from(expectedToken); + return ( + candidateBytes.length === expectedBytes.length && + timingSafeEqual(candidateBytes, expectedBytes) + ); +} + function readOrigin(req: Request): string | undefined { const origin = req.headers.get("origin")?.trim(); return origin ? origin : undefined; @@ -149,7 +171,9 @@ export function startServer( ctx: SidecarContext, preferredPort: number = SIDECAR_PORT, onShutdown?: (reason?: string) => Promise, -): { port: number } { + approvalToken = process.env.CLINE_SIDECAR_APPROVAL_TOKEN?.trim() || + randomUUID(), +): { port: number; approvalToken: string } { if (!BunRuntime) { throw new Error("sidecar must be run with Bun"); } @@ -164,7 +188,7 @@ export function startServer( server = BunRuntime.serve({ hostname: SIDECAR_HOST, port: candidate, - fetch: createFetchHandler(ctx, onShutdown), + fetch: createFetchHandler(ctx, onShutdown, approvalToken), websocket: createWebSocketHandler(ctx), }) as SidecarServer; break; @@ -177,12 +201,13 @@ export function startServer( throw lastError ?? new Error("Failed to start sidecar server"); } - return { port: server.port }; + return { port: server.port, approvalToken }; } export function createFetchHandler( ctx: SidecarContext, onShutdown?: (reason?: string) => Promise, + approvalToken = "", ) { return async (req: Request, server: SidecarServer) => { const url = new URL(req.url); @@ -208,7 +233,15 @@ export function createFetchHandler( if ( url.pathname === "/transport" && isTrustedRequestOrigin(req) && - server.upgrade(req) + server.upgrade(req, { + data: { + // Originless clients remain supported for local integrations, but only + // the browser-hosted desktop UI may receive or resolve approvals. + canApproveTools: + Boolean(readOrigin(req)) && + hasValidApprovalToken(url, approvalToken), + }, + }) ) { return undefined; } @@ -325,6 +358,7 @@ export function createWebSocketHandler(ctx: SidecarContext) { maxPayloadLength: MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES, open(ws: SidecarWebSocketClient) { ctx.wsClients.add(ws); + void syncSidecarApprovalReadiness(ctx).catch(() => {}); sendEvent(ctx, "host_ready", { pid: process.pid, mode: SIDECAR_MODE, @@ -365,6 +399,8 @@ export function createWebSocketHandler(ctx: SidecarContext) { }, close(ws: SidecarWebSocketClient) { ctx.wsClients.delete(ws); + cancelSidecarToolApprovalsForOwner(ctx, ws); + void syncSidecarApprovalReadiness(ctx).catch(() => {}); // Browser OAuth flows are interactive: if the connection that started // one goes away (webview reload, transport drop), cancel its callback // wait so the sidecar cannot retain an abandoned authorization attempt. diff --git a/apps/examples/desktop-app/sidecar/types.ts b/apps/examples/desktop-app/sidecar/types.ts index 7eeb369c58..3fde004081 100644 --- a/apps/examples/desktop-app/sidecar/types.ts +++ b/apps/examples/desktop-app/sidecar/types.ts @@ -82,6 +82,7 @@ export type ToolApprovalRequestItem = { export type PendingToolApproval = { item: ToolApprovalRequestItem; + owner: SidecarWebSocketClient; resolve: (result: ToolApprovalResult) => void; }; @@ -105,6 +106,7 @@ export type PendingAskQuestion = { }; export type SidecarWebSocketClient = { + data?: { canApproveTools?: boolean }; send: (message: string) => void; close?: () => void; }; diff --git a/apps/examples/desktop-app/webview/app/page.tsx b/apps/examples/desktop-app/webview/app/page.tsx index 723df455ad..62b9cec21c 100644 --- a/apps/examples/desktop-app/webview/app/page.tsx +++ b/apps/examples/desktop-app/webview/app/page.tsx @@ -432,11 +432,17 @@ export default function Home() { onNavigateBack={handleNavigateBack} onNavigateForward={handleNavigateForward} onNewThread={handleNewThread} + onOpenSessionById={handleOpenSessionById} onSettingsSectionChange={handleSettingsSectionChange} sessionHistory={sessionHistory} setView={handleViewChange} settingsSection={settingsSection} view={view} + workspaceRoot={ + activeThread?.historySession?.workspaceRoot || + activeThread?.historySession?.cwd || + historyWorkspacePaths[0] + } canNavigateBack={navigation.back.length > 0} canNavigateForward={navigation.forward.length > 0} /> @@ -1642,6 +1648,7 @@ function ChatThreadPane({ ) : undefined } onListGitBranches={listGitBranches} + onOpenSession={onOpenSessionById} onSwitchGitBranch={switchGitBranch} /> diff --git a/apps/examples/desktop-app/webview/components/agenda-task-review-dialog.tsx b/apps/examples/desktop-app/webview/components/agenda-task-review-dialog.tsx new file mode 100644 index 0000000000..23b65a112b --- /dev/null +++ b/apps/examples/desktop-app/webview/components/agenda-task-review-dialog.tsx @@ -0,0 +1,170 @@ +"use client"; + +import type { AgendaTaskRecord } from "@cline/shared"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export function AgendaTaskReviewDialog({ + task, + open, + pending, + confirmLabel = "Approve", + rejectLabel = "Reject", + onOpenChange, + onConfirm, + onReject, +}: { + task: AgendaTaskRecord | null; + open: boolean; + pending: boolean; + confirmLabel?: string; + rejectLabel?: string; + onOpenChange: (open: boolean) => void; + onConfirm: (task: AgendaTaskRecord) => void | Promise; + onReject?: (task: AgendaTaskRecord) => void | Promise; +}) { + return ( + + + {task ? ( + <> + + {task.title} + + Review the exact revision before it can start a new agent + session. + + +
+
+ + + + + + + + + + {task.cwd ? ( + + ) : null} + +
+ {task.description ? ( + + ) : null} + + {task.systemPrompt ? ( + + ) : null} + {task.resourcePaths.length > 0 ? ( +
+

Files

+
    + {task.resourcePaths.map((path) => ( +
  • + {path} +
  • + ))} +
+
+ ) : null} +
+ + + + + + ) : null} +
+
+ ); +} + +function ReviewField({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function ReviewText({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+
+ {value} +
+
+ ); +} diff --git a/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx b/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx index 5adde4b0c9..c2caa9a838 100644 --- a/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx +++ b/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import type { AgendaTaskRecord } from "@cline/shared"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -15,8 +16,20 @@ import type { UseSessionHistoryResult, } from "@/hooks/use-session-history"; -const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); -vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } })); +const desktopMocks = vi.hoisted(() => ({ + invoke: vi.fn(), + createAgendaTask: vi.fn(), + listAgendaTasks: vi.fn(), + approveAgendaTask: vi.fn(), + cancelAgendaTask: vi.fn(), + runAgendaTask: vi.fn(), + getAgendaAutomationPolicy: vi.fn(), + setAgendaAutomationPolicy: vi.fn(), + subscribe: vi.fn(() => () => undefined), + subscribeTransportState: vi.fn(() => () => undefined), +})); +const { invoke } = desktopMocks; +vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks })); let container: HTMLDivElement; let root: Root; @@ -80,6 +93,20 @@ async function hover(element: Element): Promise { }); } +async function changeField( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +): Promise { + await act(async () => { + const prototype = Object.getPrototypeOf(element) as object; + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + setter?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + await Promise.resolve(); + }); +} + function buttonWithText(text: string, rootNode: ParentNode = container) { const button = [ ...rootNode.querySelectorAll("button"), @@ -117,6 +144,29 @@ beforeEach(() => { window.localStorage.clear(); invoke.mockReset(); invoke.mockRejectedValue(new Error("No Cline account auth token found")); + desktopMocks.createAgendaTask.mockReset(); + desktopMocks.listAgendaTasks.mockReset(); + desktopMocks.listAgendaTasks.mockResolvedValue([]); + desktopMocks.approveAgendaTask.mockReset(); + desktopMocks.cancelAgendaTask.mockReset(); + desktopMocks.runAgendaTask.mockReset(); + desktopMocks.getAgendaAutomationPolicy.mockReset(); + desktopMocks.getAgendaAutomationPolicy.mockResolvedValue({ + scopeKey: "global", + mode: "manual", + applyToAgentCreated: true, + maxConcurrentRuns: 1, + maxChainDepth: 3, + maxStartsPerHour: 20, + updatedAt: "2026-08-13T00:00:00.000Z", + }); + desktopMocks.setAgendaAutomationPolicy.mockReset(); + desktopMocks.subscribe.mockReset(); + desktopMocks.subscribe.mockImplementation(() => () => undefined); + desktopMocks.subscribeTransportState.mockReset(); + desktopMocks.subscribeTransportState.mockImplementation( + () => () => undefined, + ); Object.defineProperty(window, "matchMedia", { configurable: true, value: vi.fn(() => ({ @@ -140,6 +190,251 @@ afterEach(async () => { }); describe("AgentSidebar session organization", () => { + it("shows an unread dot when a new Todo item arrives and clears it on open", async () => { + const eventHandlers = new Map void>(); + desktopMocks.subscribe.mockImplementation( + (eventName: string, handler: () => void) => { + eventHandlers.set(eventName, handler); + return () => eventHandlers.delete(eventName); + }, + ); + + await act(async () => { + root.render( + + + , + ); + }); + await vi.waitFor(() => + expect(desktopMocks.listAgendaTasks).toHaveBeenCalled(), + ); + expect( + container.querySelector('[data-testid="new-todo-indicator"]'), + ).toBeNull(); + + desktopMocks.listAgendaTasks.mockResolvedValue([makeAgendaTask()]); + await act(async () => { + eventHandlers.get("task.created")?.(); + }); + await vi.waitFor(() => + expect( + container.querySelector('[data-testid="new-todo-indicator"]'), + ).not.toBeNull(), + ); + + await click( + container.querySelector('[aria-label="Show Agenda"]') as Element, + ); + expect( + container.querySelector('[data-testid="new-todo-indicator"]'), + ).toBeNull(); + }); + + it("shows pending Agenda work and requires approval before run", async () => { + const task = makeAgendaTask(); + desktopMocks.listAgendaTasks.mockResolvedValue([task]); + desktopMocks.approveAgendaTask.mockResolvedValue({ + ...task, + status: "approved", + revision: 2, + }); + + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + await click( + container.querySelector('[aria-label="Show Agenda"]') as Element, + ); + + expect(container.textContent).toContain("Review PR checks"); + expect(container.textContent).toContain("cline"); + expect(container.textContent).not.toContain("P1 · pending approval"); + expect(desktopMocks.listAgendaTasks).toHaveBeenCalledWith({ + statuses: ["pending_approval", "approved", "in_progress", "failed"], + workspaceRoot: "/projects/current", + limit: 200, + }); + const approve = container.querySelector( + '[aria-label="Approve Review PR checks"]', + ); + expect(approve).not.toBeNull(); + expect(approve?.className).toContain("text-emerald-500!"); + expect( + container.querySelector('[aria-label="Cancel Review PR checks"]') + ?.className, + ).toContain("text-destructive!"); + expect(approve?.closest(".group")?.className).toContain("max-w-full"); + expect( + buttonWithText("Review PR checks").querySelector(".truncate"), + ).not.toBeNull(); + expect( + container.querySelector('[aria-label="Run Review PR checks"]'), + ).toBeNull(); + + await click(buttonWithText("Review PR checks")); + expect(desktopMocks.approveAgendaTask).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain( + "Review CI and report failures.", + ); + expect(buttonWithText("Reject", document)).toBeDefined(); + await click(buttonWithText("Approve", document)); + expect(desktopMocks.approveAgendaTask).toHaveBeenCalledWith({ + taskId: "task-1", + expectedRevision: 1, + }); + }); + + it("uses each displayed Agenda revision when running or cancelling", async () => { + const runnable = makeAgendaTask({ + taskId: "task-run", + title: "Run task", + status: "approved", + revision: 4, + }); + const cancellable = makeAgendaTask({ + taskId: "task-cancel", + title: "Cancel task", + status: "approved", + revision: 9, + }); + desktopMocks.listAgendaTasks.mockResolvedValue([runnable, cancellable]); + desktopMocks.runAgendaTask.mockResolvedValue({ + task: { ...runnable, status: "in_progress" }, + }); + desktopMocks.cancelAgendaTask.mockResolvedValue({ + ...cancellable, + status: "cancelled", + }); + + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + await click( + container.querySelector('[aria-label="Show Agenda"]') as Element, + ); + + await click( + container.querySelector('[aria-label="Run Run task"]') as Element, + ); + expect(desktopMocks.runAgendaTask).toHaveBeenCalledWith({ + taskId: "task-run", + expectedRevision: 4, + }); + + await click( + container.querySelector('[aria-label="Cancel Cancel task"]') as Element, + ); + expect(desktopMocks.cancelAgendaTask).toHaveBeenCalledWith({ + taskId: "task-cancel", + expectedRevision: 9, + }); + }); + + it("creates a workspace task with the selected priority, expiry, and model", async () => { + const created = makeAgendaTask({ + taskId: "task-created", + title: "Investigate the regression", + }); + desktopMocks.createAgendaTask.mockResolvedValue(created); + window.localStorage.setItem( + "cline.code.model-selection.v1", + JSON.stringify({ + lastProvider: "openrouter", + lastModelByProvider: { openrouter: "anthropic/claude-sonnet-4.6" }, + }), + ); + + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + + expect(container.querySelector('[aria-label="Agenda"]')).toBeNull(); + await click( + container.querySelector('[aria-label="Show Agenda"]') as Element, + ); + await click( + container.querySelector('[aria-label="Create Todo item"]') as Element, + ); + const title = + document.querySelector("#agenda-task-title"); + const instructions = document.querySelector( + "#agenda-task-instructions", + ); + expect(title).not.toBeNull(); + expect(instructions).not.toBeNull(); + await changeField(title as HTMLInputElement, "Investigate the regression"); + await changeField( + instructions as HTMLTextAreaElement, + "Inspect the failing build and implement a fix.", + ); + await click(buttonWithText("Add to Agenda", document)); + + await vi.waitFor(() => + expect(desktopMocks.createAgendaTask).toHaveBeenCalledOnce(), + ); + const input = desktopMocks.createAgendaTask.mock.calls[0]?.[0]; + expect(input).toMatchObject({ + type: "todo", + title: "Investigate the regression", + instructions: "Inspect the failing build and implement a fix.", + scope: "workspace", + workspaceRoot: "/projects/current", + priority: 3, + modelSelection: { + providerId: "openrouter", + modelId: "anthropic/claude-sonnet-4.6", + }, + automationEligible: true, + }); + expect(Date.parse(input.expiresAt)).toBeGreaterThan(Date.now()); + }); + it("filters scheduled sessions without changing their titles", async () => { const scheduled = { ...makeThread("scheduled", 1), @@ -568,8 +863,10 @@ describe("AgentSidebar session organization", () => { }); const logo = container.querySelector('[aria-label="Cline home"]'); + const showAgenda = container.querySelector('[aria-label="Show Agenda"]'); const newSession = container.querySelector('[aria-label="New Session"]'); expect(logo).not.toBeNull(); + expect(showAgenda).not.toBeNull(); expect(newSession).not.toBeNull(); expect(newSession?.textContent).toBe(""); await click(newSession as Element); @@ -683,3 +980,29 @@ describe("AgentSidebar session organization", () => { ).toContain("Settings"); }); }); + +function makeAgendaTask( + overrides: Partial = {}, +): AgendaTaskRecord { + return { + taskId: "task-1", + type: "follow-up", + status: "pending_approval", + title: "Review PR checks", + description: "Confirm that CI passed.", + instructions: "Review CI and report failures.", + scope: "workspace", + workspaceRoot: "/projects/cline", + resourcePaths: [], + priority: 1, + availableAt: "2026-08-13T00:00:00.000Z", + expiresAt: "2099-08-20T00:00:00.000Z", + automationEligible: true, + revision: 1, + createdBy: { kind: "agent" }, + updatedBy: { kind: "agent" }, + createdAt: "2026-08-13T00:00:00.000Z", + updatedAt: "2026-08-13T00:00:00.000Z", + ...overrides, + }; +} diff --git a/apps/examples/desktop-app/webview/components/agent-sidebar.tsx b/apps/examples/desktop-app/webview/components/agent-sidebar.tsx index 222f7b645e..574ef10e45 100644 --- a/apps/examples/desktop-app/webview/components/agent-sidebar.tsx +++ b/apps/examples/desktop-app/webview/components/agent-sidebar.tsx @@ -1,13 +1,22 @@ "use client"; +import type { + AgendaTaskPriority, + AgendaTaskRecord, + AgendaTaskType, + HubTaskCreateInput, +} from "@cline/shared"; +import { isChatWorkspacePath } from "@cline/shared/browser"; import { Activity, ArrowDownUp, Bot, + Check, ChevronDown, ChevronLeft, ChevronRight, CircleUserRound, + ClipboardList, Clock3, Code, FileText, @@ -15,8 +24,10 @@ import { FolderTree, GitFork, Loader2, + MessageSquarePlus, PanelLeftOpen, Pencil, + Play, Plug, Plus, Radio, @@ -27,6 +38,8 @@ import { Star, Trash2, Wrench, + X, + Zap, } from "lucide-react"; import { type ReactNode, @@ -36,6 +49,7 @@ import { useRef, useState, } from "react"; +import { AgendaTaskReviewDialog } from "@/components/agenda-task-review-dialog"; import { AppUpdateIndicator } from "@/components/app-update-indicator"; import { ClineLogo } from "@/components/cline-logo"; import { @@ -56,6 +70,14 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -73,6 +95,7 @@ import { import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useSidebar } from "@/components/ui/sidebar"; +import { Textarea } from "@/components/ui/textarea"; import { normalizeTitle } from "@/components/utils"; import { CUSTOMIZATION_SECTIONS, @@ -80,6 +103,7 @@ import { type SettingsSection, } from "@/components/views/settings/sections"; import { useAccount } from "@/contexts/account-context"; +import { useAgendaAutomation, useAgendaTasks } from "@/hooks/use-agenda-tasks"; import type { SessionThread, UseSessionHistoryResult, @@ -91,6 +115,7 @@ import { productNameForVersion, } from "@/lib/app-channel"; import { desktopClient } from "@/lib/desktop-client"; +import { readModelSelectionStorageFromWindow } from "@/lib/model-selection"; import { ALL_SESSION_SOURCES, filterSessionsBySource, @@ -110,6 +135,7 @@ type AppView = "chat" | "sessions" | "settings"; const filterOptions = ["All", "Running", "Schedules", "Favorites"] as const; type FilterOption = (typeof filterOptions)[number]; type SidebarSortMode = "time" | "project"; +type SidebarContent = "sessions" | "agenda"; type DesktopProcessContext = { appVersion?: unknown; hub?: { @@ -216,12 +242,14 @@ export function AgentSidebar({ onNavigateBack, onNavigateForward, onNewThread, + onOpenSessionById, onSettingsSectionChange, setView, settingsSection, view, activeSessionId, sessionHistory, + workspaceRoot, }: { canNavigateBack?: boolean; canNavigateForward?: boolean; @@ -229,12 +257,14 @@ export function AgentSidebar({ onNavigateBack?: () => void; onNavigateForward?: () => void; onNewThread?: () => void; + onOpenSessionById?: (sessionId: string) => void | Promise; onSettingsSectionChange: (section: SettingsSection) => void; setView: (view: AppView) => void; settingsSection: SettingsSection; view: AppView; activeSessionId?: string | null; sessionHistory: UseSessionHistoryResult; + workspaceRoot?: string; }) { const { isMobile, setOpen, setOpenMobile, state } = useSidebar(); const isCollapsed = !isMobile && state === "collapsed"; @@ -265,6 +295,10 @@ export function AgentSidebar({ const [filter, setFilter] = useState("All"); const [sourceFilter, setSourceFilter] = useState(ALL_SESSION_SOURCES); const [sortMode, setSortMode] = useState("time"); + const [sidebarContent, setSidebarContent] = + useState("sessions"); + const [hasNewTodoTasks, setHasNewTodoTasks] = useState(false); + const knownAgendaTaskIdsRef = useRef | null>(null); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [showMoreCount, setShowMoreCount] = useState( @@ -283,6 +317,38 @@ export function AgentSidebar({ >({}); const [appVersion, setAppVersion] = useState(null); const [hubStatus, setHubStatus] = useState(null); + const normalizedWorkspaceRoot = workspaceRoot?.trim() ?? ""; + const agendaWorkspaceRoot = + normalizedWorkspaceRoot && !isChatWorkspacePath(normalizedWorkspaceRoot) + ? normalizedWorkspaceRoot + : undefined; + const agenda = useAgendaTasks( + { + statuses: ["pending_approval", "approved", "in_progress", "failed"], + workspaceRoot: agendaWorkspaceRoot, + limit: 200, + }, + view !== "settings", + ); + const agendaAutomation = useAgendaAutomation(view !== "settings"); + + useEffect(() => { + if (view === "settings") { + knownAgendaTaskIdsRef.current = null; + return; + } + if (agenda.isLoading) return; + const currentTaskIds = new Set(agenda.tasks.map((task) => task.taskId)); + const knownTaskIds = knownAgendaTaskIdsRef.current; + if ( + knownTaskIds !== null && + sidebarContent !== "agenda" && + agenda.tasks.some((task) => !knownTaskIds.has(task.taskId)) + ) { + setHasNewTodoTasks(true); + } + knownAgendaTaskIdsRef.current = currentTaskIds; + }, [agenda.isLoading, agenda.tasks, sidebarContent, view]); const loadProcessContext = useCallback(async () => { try { @@ -354,6 +420,25 @@ export function AgentSidebar({ const closeMobileSidebar = useCallback(() => { if (isMobile) setOpenMobile(false); }, [isMobile, setOpenMobile]); + const openAgendaSession = useCallback( + (task: AgendaTaskRecord) => { + if (!task.lastSessionId) return; + void onOpenSessionById?.(task.lastSessionId); + closeMobileSidebar(); + }, + [closeMobileSidebar, onOpenSessionById], + ); + const runAgendaTask = useCallback( + async (task: AgendaTaskRecord) => { + try { + const started = await agenda.runTask(task); + if (started.lastSessionId) openAgendaSession(started); + } catch { + // The queue hook exposes the error inline and refreshes after recovery. + } + }, + [agenda.runTask, openAgendaSession], + ); const openThread = useCallback( (threadId: string) => { @@ -392,6 +477,12 @@ export function AgentSidebar({ const navigateForward = useCallback(() => { onNavigateForward?.(); }, [onNavigateForward]); + const toggleSidebarContent = useCallback(() => { + const next = sidebarContent === "agenda" ? "sessions" : "agenda"; + setSidebarContent(next); + if (next === "agenda") setHasNewTodoTasks(false); + if (next === "agenda" && view === "settings") setView("chat"); + }, [setView, sidebarContent, view]); const startRenameThread = useCallback((thread: Thread) => { setEditingSessionId(thread.id); @@ -705,16 +796,43 @@ export function AgentSidebar({ {!isCollapsed ? : null} {!isCollapsed ? ( - +
+ + +
) : null} @@ -747,6 +865,36 @@ export function AgentSidebar({ onSelect={openSettingsSection} /> + ) : sidebarContent === "agenda" ? ( + { + return agenda.cancelTask(task).catch(() => undefined); + }} + onOpen={openAgendaSession} + onRun={(task) => void runAgendaTask(task)} + onToggleAutomation={() => { + void agendaAutomation + .setAutomatic( + agendaAutomation.policy?.mode !== "auto_start" && + agendaAutomation.policy?.mode !== "unattended", + ) + .catch(() => undefined); + }} + pendingTaskIds={agenda.pendingTaskIds} + tasks={agenda.tasks} + workspaceRoot={agendaWorkspaceRoot} + /> ) : ( <>
@@ -1023,6 +1171,421 @@ export function AgentSidebar({ ); } +function AgendaSection({ + tasks, + workspaceRoot, + isLoading, + error, + pendingTaskIds, + automatic, + automationDisabled, + onApprove, + onRun, + onOpen, + onCancel, + onToggleAutomation, + onCreate, +}: { + tasks: AgendaTaskRecord[]; + workspaceRoot?: string; + isLoading: boolean; + error: string | null; + pendingTaskIds: ReadonlySet; + automatic: boolean; + automationDisabled: boolean; + onApprove: (task: AgendaTaskRecord) => Promise; + onRun: (task: AgendaTaskRecord) => void; + onOpen: (task: AgendaTaskRecord) => void; + onCancel: (task: AgendaTaskRecord) => void | Promise; + onToggleAutomation: () => void; + onCreate: (input: HubTaskCreateInput) => Promise; +}) { + const [createOpen, setCreateOpen] = useState(false); + const [reviewTask, setReviewTask] = useState(null); + const [creating, setCreating] = useState(false); + const [title, setTitle] = useState(""); + const [instructions, setInstructions] = useState(""); + const [type, setType] = useState("todo"); + const [priority, setPriority] = useState(3); + const [scope, setScope] = useState<"workspace" | "global">( + workspaceRoot ? "workspace" : "global", + ); + const [expiresAt, setExpiresAt] = useState(() => + new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 16), + ); + const resetCreateForm = () => { + setTitle(""); + setInstructions(""); + setType("todo"); + setPriority(3); + setScope(workspaceRoot ? "workspace" : "global"); + setExpiresAt( + new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 16), + ); + }; + const submitCreate = async () => { + const normalizedTitle = title.trim(); + const normalizedInstructions = instructions.trim(); + if (!normalizedTitle || !normalizedInstructions || !expiresAt) return; + const expiration = new Date(expiresAt); + if (Number.isNaN(expiration.getTime())) return; + setCreating(true); + try { + const rememberedModel = readModelSelectionStorageFromWindow(); + const providerId = rememberedModel.lastProvider.trim(); + const modelId = providerId + ? rememberedModel.lastModelByProvider[providerId]?.trim() + : undefined; + await onCreate({ + type, + title: normalizedTitle, + instructions: normalizedInstructions, + scope: scope === "workspace" && workspaceRoot ? "workspace" : "global", + workspaceRoot: + scope === "workspace" && workspaceRoot ? workspaceRoot : undefined, + priority, + modelSelection: providerId + ? { providerId, ...(modelId ? { modelId } : {}) } + : undefined, + expiresAt: expiration.toISOString(), + automationEligible: true, + }); + setCreateOpen(false); + resetCreateForm(); + } catch { + // The Agenda hook surfaces the manager's structured error inline. + } finally { + setCreating(false); + } + }; + return ( +
+
+ Todo +
+ + { + setCreateOpen(open); + if (open) setScope(workspaceRoot ? "workspace" : "global"); + }} + open={createOpen} + > + + + + + + Create Todo Item + +
+ +