diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 53d848fda..5a5aedcbc 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -42858,6 +42858,49 @@ it("fails a cleanly ended OpenCode event stream and clears active child sessions expect(doneEvent.event.modelId).toBe("droid/custom:claude-sonnet-5-thinking-32000"); }); + it("sends Droid screenshots as attachment paths over worker IPC", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const imagePath = path.join(tmpRoot, "droid-shot.png"); + fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "droid", + model: "custom:claude-sonnet-5-thinking-32000", + modelId: "droid/custom:claude-sonnet-5-thinking-32000", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Look at this screenshot.", + attachments: [{ path: imagePath, type: "image" }], + }, { awaitDispatch: true }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "done" && event.sessionId === session.id, + ); + + const sentImages = mockState.droidPromptCalls[0]?.images as Array<{ + path?: string; + data?: string; + mimeType?: string; + rootPath?: string; + }> | undefined; + expect(sentImages).toHaveLength(1); + expect(sentImages?.[0]?.data).toBeUndefined(); + expect(sentImages?.[0]?.mimeType).toBe("image/png"); + expect(sentImages?.[0]?.rootPath).toBe(tmpRoot); + expect(path.basename(sentImages?.[0]?.path ?? "")).toBe("droid-shot.png"); + expect(String(mockState.droidPromptCalls[0]?.promptText ?? "")).toContain("Look at this screenshot."); + expect(String(mockState.droidPromptCalls[0]?.promptText ?? "")).not.toMatch(/iVBORw0KGgo/u); + }); + it("uses Droid spec mode for ADE plan mode", async () => { const events: AgentChatEventEnvelope[] = []; const { service } = createService({ @@ -44542,8 +44585,11 @@ describe("orchestrator-lead provider-native tool denial", () => { expect(mockState.droidAcquireCalls.at(-1)?.settings).toMatchObject({ disabledToolCategories: ["edit", "execute"], }); - expect(mockState.droidPromptCalls.at(-1)?.settings).toMatchObject({ - disabledToolCategories: ["edit", "execute"], + // awaitDispatch returns at onDispatched, which is before sendPrompt. + await vi.waitFor(() => { + expect(mockState.droidPromptCalls.at(-1)?.settings).toMatchObject({ + disabledToolCategories: ["edit", "execute"], + }); }); const worker = await service.createSession({ @@ -44554,8 +44600,10 @@ describe("orchestrator-lead provider-native tool denial", () => { ...workerArgs(created), }); await service.sendMessage({ sessionId: worker.id, text: "Do the work." }, { awaitDispatch: true }); - expect(mockState.droidPromptCalls.at(-1)?.settings) - .not.toHaveProperty("disabledToolCategories"); + await vi.waitFor(() => { + expect(mockState.droidPromptCalls.at(-1)?.settings) + .not.toHaveProperty("disabledToolCategories"); + }); } finally { await orchestrationService.dispose(); } diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index f34d4a3df..aa6719458 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -36705,7 +36705,7 @@ export function createAgentChatService(args: { ], }); - const buildPiWorkerPrompt = async ( + const buildPathOnlyWorkerPrompt = async ( promptText: string, resolvedAttachments: ResolvedAgentChatFileRef[], ): Promise<{ promptText: string; images: Array<{ path: string; mimeType: string; rootPath: string }> }> => { @@ -36720,6 +36720,8 @@ export function createAgentChatService(args: { images: pathImagesFromResolved(resolvedAttachments), }; }; + const buildPiWorkerPrompt = buildPathOnlyWorkerPrompt; + const buildDroidWorkerPrompt = buildPathOnlyWorkerPrompt; const mapChatDecisionToDroidPermission = ( decision: AgentChatApprovalDecision | undefined, @@ -39566,14 +39568,10 @@ export function createAgentChatService(args: { "## User Request", composed, ].join("\n"); - const promptBlocks = await buildAgentPromptBlocks(sdkInput, args.resolvedAttachments); - const sdkPromptText = promptBlocks - .filter((block): block is { type: "text"; text: string } => block.type === "text") - .map((block) => block.text) - .join("\n\n"); - const images = promptBlocks - .filter((block): block is { type: "image"; data: string; mimeType: string } => block.type === "image") - .map((block) => ({ data: block.data, mimeType: block.mimeType })); + const { promptText: sdkPromptText, images } = await buildDroidWorkerPrompt( + sdkInput, + args.resolvedAttachments, + ); const result = await runtime.sdk.sendPrompt({ promptText: sdkPromptText, ...(images.length ? { images } : {}), diff --git a/apps/desktop/src/main/services/chat/droidSdkPool.test.ts b/apps/desktop/src/main/services/chat/droidSdkPool.test.ts index 8efafddf6..8646f5e81 100644 --- a/apps/desktop/src/main/services/chat/droidSdkPool.test.ts +++ b/apps/desktop/src/main/services/chat/droidSdkPool.test.ts @@ -20,8 +20,10 @@ class FakeSdkChild extends EventEmitter { killed = false; disposeCount = 0; initPayloads: unknown[] = []; + sent: unknown[] = []; send(message: { type?: string; requestId?: string; payload?: unknown }): boolean { + this.sent.push(message); if (message.type === "init" && message.requestId) { this.initPayloads.push(message.payload); queueMicrotask(() => { @@ -37,6 +39,16 @@ class FakeSdkChild extends EventEmitter { }); }); } + if (message.type === "send" && message.requestId) { + queueMicrotask(() => { + this.emit("message", { + type: "response", + requestId: message.requestId, + ok: true, + result: {}, + }); + }); + } if (message.type === "dispose") { this.disposeCount += 1; } @@ -134,6 +146,46 @@ describe("Droid SDK pool", () => { expect(child.disposeCount).toBe(1); }); + it("sends screenshot paths over worker IPC instead of inline bytes", async () => { + const child = new FakeSdkChild(); + forkMock.mockReturnValue(child); + const poolKey = `test-image-paths:${Date.now()}:${Math.random()}`; + const acquired = await acquireDroidSdkConnection({ + poolKey, + droidPath: "/usr/local/bin/droid", + workspacePath: path.join(os.tmpdir(), "ade-workspace"), + sessionId: "session-1", + settings: { + modelId: "droid-model", + autonomyLevel: "medium", + interactionMode: "auto", + }, + }); + + await acquired.pooled.sendPrompt({ + promptText: "compare these screens", + images: [ + { path: "/repo/.ade/attachments/a.png", mimeType: "image/png", rootPath: "/repo" }, + { path: "/repo/.ade/attachments/b.png", mimeType: "image/png", rootPath: "/repo" }, + ], + settings: { modelId: "droid-model" }, + }); + + const sendReq = child.sent.find((message) => ( + message + && typeof message === "object" + && "type" in message + && message.type === "send" + )) as { payload?: { images?: Array<{ path?: string; data?: string }> } } | undefined; + expect(sendReq?.payload?.images).toEqual([ + { path: "/repo/.ade/attachments/a.png", mimeType: "image/png", rootPath: "/repo" }, + { path: "/repo/.ade/attachments/b.png", mimeType: "image/png", rootPath: "/repo" }, + ]); + expect(sendReq?.payload?.images?.some((image) => image.data)).toBeFalsy(); + + releaseDroidSdkConnection(poolKey, acquired.generation); + }); + it("rejects initialization instead of throwing when the worker IPC channel closes", async () => { forkMock.mockReturnValue(new ExitingBeforeInitChild()); const poolKey = `test-exit:${Date.now()}:${Math.random()}`; diff --git a/apps/desktop/src/main/services/chat/droidSdkProtocol.ts b/apps/desktop/src/main/services/chat/droidSdkProtocol.ts index f4438ad7d..300ce8787 100644 --- a/apps/desktop/src/main/services/chat/droidSdkProtocol.ts +++ b/apps/desktop/src/main/services/chat/droidSdkProtocol.ts @@ -120,10 +120,17 @@ export type DroidSdkWorkerInit = { allowedMcpServerNames?: string[]; }; -export type DroidSdkUserImage = { - data: string; - mimeType: string; -}; +/** + * Worker-IPC image reference. Prefer `path` — never put multi-megabyte + * screenshot bytes on this object. The worker materializes `{ data, mimeType }` + * for `@factory/droid-sdk` locally. `data` remains for tests and tiny inline + * cases. Droid's stream API has no remote-URL image form, so `url` is not part + * of this union. Path images include `rootPath` so the worker re-opens through + * the attachment sandbox. + */ +export type DroidSdkUserImage = + | { path: string; mimeType: string; rootPath: string } + | { data: string; mimeType: string }; export type DroidSdkSendPrompt = { promptText: string; diff --git a/apps/desktop/src/main/services/chat/droidSdkWorker.ts b/apps/desktop/src/main/services/chat/droidSdkWorker.ts index 922cb652f..4ec0057d5 100644 --- a/apps/desktop/src/main/services/chat/droidSdkWorker.ts +++ b/apps/desktop/src/main/services/chat/droidSdkWorker.ts @@ -14,6 +14,7 @@ import { droidDisabledToolIdsForCategories, droidInteractionModeValue, droidMcpT import { loadDroidSdk } from "../ai/droidSdkLoader"; import { summarizeDroidAskUser } from "./droidSdkAskUser"; import { ensureDroidSpawnsAreWindowless } from "./droidSdkWindowsHide"; +import { materializeWorkerImages } from "./workerAttachmentImages"; // Must run before the SDK spawns `droid`; see droidSdkWindowsHide.ts. ensureDroidSpawnsAreWindowless(); @@ -408,13 +409,19 @@ async function sendPrompt(payload: DroidSdkWorkerRequest & { type: "send" }): Pr let tokenUsage: unknown = null; let firstError: unknown = null; try { - const images = payload.payload.images?.map((image) => ({ - type: "base64" as const, - data: image.data, - mediaType: image.mimeType as DroidSdkTypes.Base64ImageSource["mediaType"], - })); + const materialized = await materializeWorkerImages(payload.payload.images, { label: "Droid SDK" }); + const images = materialized.map((image) => { + if (!("data" in image)) { + throw new Error("Droid SDK image URLs are not supported."); + } + return { + type: "base64" as const, + data: image.data, + mediaType: image.mimeType as DroidSdkTypes.Base64ImageSource["mediaType"], + }; + }); for await (const event of session.stream(payload.payload.promptText, { - ...(images?.length ? { images } : {}), + ...(images.length ? { images } : {}), abortSignal: controller.signal, })) { if ((event as { type?: string }).type === "token_usage_update") tokenUsage = event; diff --git a/apps/desktop/src/main/services/chat/workerAttachmentImages.ts b/apps/desktop/src/main/services/chat/workerAttachmentImages.ts index 0f4c9ea89..3dfa5af8f 100644 --- a/apps/desktop/src/main/services/chat/workerAttachmentImages.ts +++ b/apps/desktop/src/main/services/chat/workerAttachmentImages.ts @@ -23,7 +23,8 @@ export type WorkerMaterializedImage = /** * Path-only worker-IPC images. Never inline bytes — stuffing screenshot * base64 through `child.send` JSON can fill the pipe and stall the turn. - * Remote URLs are a Cursor-only send shape and stay at that call site. + * Remote URLs are a Cursor-only send shape and stay at that call site; + * Pi and Droid turn image URLs into prompt text instead. */ export function workerPathImagesFromAttachments( attachments: readonly WorkerPathImageSource[], diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 872c811ba..cce024950 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -75,14 +75,14 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/cursorSdkWorker.ts` | Node worker that hosts the official `@cursor/sdk` and bridges it to the main process via the JSON line protocol in `cursorSdkProtocol.ts`. It creates the SDK local agent platform with the lane workspace/state root, configures local agents to use HTTP/1 by default (`ADE_CURSOR_SDK_USE_HTTP1_FOR_AGENT=0` disables it), enables SDK local agent retries, passes ADE mode/idempotency keys on sends, and tolerates stream-iteration failures long enough to call `run.wait()` and emit a structured terminal result. The SDK's `local.force` send option (expire the currently active persisted run before starting this message as a new follow-up) is wired to the explicit `forceExpireActiveRun` payload flag and is set **only** on ADE's automatic recovery re-send — a normal send that expired a genuinely running turn would discard its output. User images are materialized here from attachment paths or URLs (`workerAttachmentImages.ts`) rather than as base64 on the JSON IPC pipe — several large screenshots on `child.send` can stall the turn so Cursor never sees the message. | | `apps/desktop/src/main/services/chat/cursorSdkErrors.ts` | Cursor SDK error normalization helpers shared by the worker: extracts `code`, `status`, `requestId`, `operation`, and `endpoint` from SDK errors/results, reads terminal run details through the public local store API, and classifies resource/backoff vs transport failures without reaching into private SDK run fields. Classification yields a bare `CursorSdkErrorKind`; there is no companion `retryable` bit, because what a caller does about a failure (recycle the thread, surface a rate limit, re-auth) is decided per call site rather than encoded in the classifier. | | `apps/desktop/src/main/services/chat/cursorSdkProtocol.ts` | Shared types for the worker IPC: chat mode, approval policy, sandbox mode, hook decisions, hook requests, `CursorSdkModelParameterValue`, `CursorSdkWorkerInit`, local/cloud send payloads, SDK request ids, and `CursorSdkErrorDetail`. User images on those payloads are path/URL references (`CursorSdkUserImage`), not inlined screenshot bytes. It exports Cursor-specific error classifiers for transport (`nghttp2`, dropped sockets, stream closures, plus the socket-side cousins `ECANCELED` / `EPIPE` / `write after end`, which poison the server-side agent thread the same way) and backoff/resource exhaustion (`resource_exhausted`, `rate_limited`, `NGHTTP2_ENHANCE_YOUR_CALM`, 429-style text) so UI/service paths present rate-limit and network failures consistently. `classifyCursorSdkErrorText` returns a bare `CursorSdkErrorKind` (`auth` / `rate_limit` / `network` / `busy` / `not_found` / `unknown`). The expired-short-lived-access-token signature lives here too, as one greppable literal (`CURSOR_SDK_STALE_ACCESS_TOKEN_TEXT`) plus `isCursorSdkStaleAccessTokenText` (matches the sentence's two halves independently, so a reflowed clause or a request-id suffix still matches, while a genuinely bad API key does not) and `readCursorSdkStaleTokenFailure`, which reads the worker's synthetic terminal `status: ERROR` event into a `CursorSdkStaleTokenFailure` (`turnId`, message, optional code and request id) in one pass, or returns `null` for any other error. `CursorSdkPermissionPolicy.fullAuto` is a permission-mode marker only — it separates full-auto sessions into their own worker pool and labels logs, and deliberately does **not** map onto the SDK's `local.force`; run expiry is the separate recovery-only `CursorSdkSendPrompt.forceExpireActiveRun`. | -| `apps/desktop/src/main/services/chat/workerAttachmentImages.ts` | Shared forked-worker image IPC. Composer screenshots become `{ path, mimeType, rootPath }` (Cursor also appends `{ url }` for remote images); the worker re-opens the existing `.ade/attachments` file through `readFileWithinRootSecure` (10 MB cap, same as temp attachments) instead of stuffing screenshot base64 through `child.send`. | +| `apps/desktop/src/main/services/chat/workerAttachmentImages.ts` | Shared forked-worker image IPC. Composer screenshots become `{ path, mimeType, rootPath }` (Cursor also appends `{ url }` for remote images; Pi and Droid reject remote `url` because their prompt APIs have no URL form); the worker re-opens the existing `.ade/attachments` file through `readFileWithinRootSecure` (10 MB cap, same as temp attachments) instead of stuffing screenshot base64 through `child.send`. | | `apps/desktop/src/main/services/chat/cursorSdkPolicy.ts` | Maps ADE permission modes onto Cursor SDK chat mode + approval policy + sandbox mode (`ade` / `cursor-native` / `off`) plus the `fullAuto` marker; decides which tool calls auto-approve and which require a user prompt. `fullAuto` names ADE's full-auto permission mode and only affects pool partitioning and log labels — it is not a Cursor SDK option. | | `apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.ts` | Builds the system prompt the Cursor worker injects (lane context, ADE CLI guidance, persona overlays). | | `apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts` | Translates `@cursor/sdk` stream events into the ADE `AgentChatEventEnvelope` shape consumed by the renderer. SDK `task` messages remain parent-run activity summaries; typed `Task` tool calls/results produce subagent start/result events keyed by tool call id, including the returned child agent id when available. Cursor MCP calls retain provider/tool identity in `event.mcp`; generated-image tools become compact image-generation rows. On a terminal `ERROR` status it reads the worker-injected `adeErrorCode` / `adeErrorDetail`, emits stable user-facing headlines for rate-limit and transport failures (a transport failure reads **Cursor's connection dropped mid-run.** rather than leaking `NGHTTP2_INTERNAL_ERROR` or `[internal] write ECANCELED` into the transcript), preserves exact Cursor request ids/details in `detail`, and sets `errorInfo.category` to `rate_limit`, `network`, `busy`, or `auth` when classification is known. Whenever the friendly headline replaces the raw code, that code is kept as the first `detail` line so the underlying failure is still recoverable from the transcript. | | `apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts` | Probes the live `@cursor/sdk` and `cursor-agent` CLI model lists, merges their descriptors, and records `cursorAvailability` so chat sessions see SDK-capable models while Work CLI launches can include CLI-only models. Both JSON and text probes preserve aliases, descriptions, `parameters[]`, and `variants[]`; `*-fast` CLI rows are folded into their base model as `aliases` + `serviceTiers: ["fast"]` so the picker shows one model with a Fast toggle instead of duplicate "Fast" rows. Parameter and variant metadata is classified into `reasoningTiers` (`none`/`dynamic`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`/`thinking`) and `serviceTiers` (`fast`). `resolveCursorSdkModelSelectionParams` rebuilds the matching `CursorSdkModelParameterValue[]` so the SDK boot can target the right variant. The previous minimal `auto` / `composer-2` fallback list has been removed. **Cache resilience:** both the SDK and CLI caches are stale-while-revalidate — last-known-good rows are served well past the 120s freshness window (up to ~6h) and a background warm (at most one attempt per freshness window, so a broken CLI/SDK is not re-spawned on every passive read) refreshes them, so verified-provider models never blink out on passive status reads (`availableModelIds`, mobile, TUI). `markCursorModelCachesStale` ages the caches without dropping rows — generic readiness invalidation (forced status refresh, verifying any provider's key) calls it, while only a cursor key change does a full `clearCursorCliModelsCache`. Auth/SDK-resolution failures drop the SDK cache (a dead key/unusable module must not resurface phantom models); transient failures keep serving last-known-good. When the signed-in CLI reports "No models available" its cache is dropped and a provider runtime failure is surfaced (the stored login lost model access; re-auth via `cursor-agent logout`). | | `apps/desktop/src/main/services/chat/droidSdkPool.ts` | Droid SDK adapter. Forks `droidSdkWorker.cjs` per session with the caller's `baseEnv` (including ADE session id and role), exposes `acquireDroidSdkConnection` / `releaseDroidSdkConnection`, and proxies prompt sends, settings updates, permission decisions, ask-user responses, and cancellation through the worker. Resolves the Droid SDK CLI executable via `resolveDroidExecutable` (PATH + bundle + configured install paths). | -| `apps/desktop/src/main/services/chat/droidSdkWorker.ts` | Node worker that hosts `@factory/droid-sdk`. Streams SDK events back to the main process and forwards permission / ask-user prompts back through the JSON-line protocol. | -| `apps/desktop/src/main/services/chat/droidSdkProtocol.ts` | Worker IPC types: `DroidSdkSessionSettings` (autonomy level, interaction mode, reasoning effort), `DroidSdkReasoningEffort`, `DroidSdkPermissionRequest`/`Decision`, `DroidSdkAskUserRequest`/`Response`, `DroidSdkReady` (handshake with `availableModels`), and `DroidSdkSendPrompt`. | +| `apps/desktop/src/main/services/chat/droidSdkWorker.ts` | Node worker that hosts `@factory/droid-sdk`. Streams SDK events back to the main process and forwards permission / ask-user prompts back through the JSON-line protocol. User images are materialized here from attachment paths (`workerAttachmentImages.ts`) rather than as base64 on the JSON IPC pipe. | +| `apps/desktop/src/main/services/chat/droidSdkProtocol.ts` | Worker IPC types: `DroidSdkSessionSettings` (autonomy level, interaction mode, reasoning effort), `DroidSdkReasoningEffort`, `DroidSdkPermissionRequest`/`Decision`, `DroidSdkAskUserRequest`/`Response`, `DroidSdkReady` (handshake with `availableModels`), and `DroidSdkSendPrompt`. User images on send are path (or tiny inline `data`) references (`DroidSdkUserImage`), not inlined screenshot bytes; remote `url` images are not part of the union because Droid's stream API has no URL form. | | `apps/desktop/src/main/services/chat/droidSdkEventMapper.ts` | Per-session `DroidSdkEventMapperState` + `mapDroidSdkMessageToChatEvents` / `mapDroidSdkRunResultToDoneEvent`. Tracks streaming text/thinking/image item ids, maps tool calls and results, maps `mission_worker_started` / `mission_worker_completed` notifications to provider-neutral subagent lifecycle events keyed by worker session id, surfaces image content as compact generation rows, and reports token usage. Replaces the deleted `droidAcpPool.ts` + `droidAcpEventMapper` path. | | `apps/desktop/src/main/services/chat/droidModelsDiscovery.ts` | SDK-driven model probe (`listDroidModelsFromSdk`) plus the `/config.json` custom-proxy merge (`~/.factory` unless `FACTORY_HOME_OVERRIDE` is set — see [Provider config homes](agent-routing.md#provider-config-homes)). Normalizes the generic `opus` row to Opus 5 with its `high` default reasoning effort and Fast capability, while retired factory Claude ids still resolve forward (Sonnet 4.6 -> Sonnet 5, basic Opus 4.7 -> Opus 4.8) before descriptors reach desktop, mobile, or TUI model pickers. Exposes `discoverDroidSdkModelDescriptors` (alias for the legacy `discoverDroidCliModelDescriptors` while callers migrate). | | `apps/desktop/src/main/services/chat/piSdkPool.ts` | Pi adapter. Forks `piSdkWorker` per session key, exposes `acquirePiSdkConnection` / `releasePiSdkConnection`, and proxies prompts, model/thinking changes, compaction, inventory reads, `login` / `cancelLogin`, and `respondToUi`. Also routes the reverse-RPC UI channel onto `bridge.onUiRequest` / `onUiNotice` / `onUiCancel`; when no `onUiRequest` handler is installed the pool answers `{ ok: false }` immediately, so an unattended worker fails closed instead of hanging a turn. | diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index 27cfd44d9..bd4084980 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -20,7 +20,7 @@ where the machinery lives. | `apps/desktop/src/main/services/ai/authDetector.ts` | Discovers available credentials (CLI, API key, OAuth) and reports auth status. | | `apps/desktop/src/main/services/ai/codexExecutable.ts` / `droidExecutable.ts` | CLI resolution for runtimes that still need an external binary (looks on PATH, in the app bundle, then in configured install paths where supported). Claude uses the bundled Claude Agent SDK binary; Cursor and Droid run through embedded SDKs (`@cursor/sdk`, `@factory/droid-sdk`). | | `apps/desktop/src/main/services/ai/tools/systemPrompt.ts` | Adjusts the system prompt per mode (`chat`, `coding`, `planning`) and permission mode, and injects runtime-specific native-subagent versus ADE-child routing guidance. | -| `apps/desktop/src/main/services/chat/droidSdkPool.ts`, `droidSdkWorker.ts`, `droidSdkProtocol.ts`, `droidSdkEventMapper.ts` | Droid SDK adapter. `droidSdkPool` forks `droidSdkWorker.cjs` (one per session), brokers prompt sends, permission requests, ask-user prompts, and settings updates via the JSON-line protocol in `droidSdkProtocol`. `droidSdkEventMapper` translates Droid SDK events into the canonical `AgentChatEventEnvelope` shape; the per-session mapper state (`createDroidSdkEventMapperState`) tracks streaming text/thinking item ids, in-flight tool-use names, and the latest usage breakdown. | +| `apps/desktop/src/main/services/chat/droidSdkPool.ts`, `droidSdkWorker.ts`, `droidSdkProtocol.ts`, `droidSdkEventMapper.ts` | Droid SDK adapter. `droidSdkPool` forks `droidSdkWorker.cjs` (one per session), brokers prompt sends, permission requests, ask-user prompts, and settings updates via the JSON-line protocol in `droidSdkProtocol`. Send payloads carry screenshot paths (`DroidSdkUserImage`), and the worker materializes bytes locally through `workerAttachmentImages.ts`. `droidSdkEventMapper` translates Droid SDK events into the canonical `AgentChatEventEnvelope` shape; the per-session mapper state (`createDroidSdkEventMapperState`) tracks streaming text/thinking item ids, in-flight tool-use names, and the latest usage breakdown. | | `apps/desktop/src/main/services/chat/piSdkPool.ts`, `piSdkWorker.ts`, `piSdkProtocol.ts`, `piSdkEventMapper.ts` | Pi adapter. `piSdkPool` forks the worker (one per session key) and brokers prompts, model/thinking changes, compaction, inventory reads, sign-in, and the reverse-RPC UI channel described below. `piSdkProtocol` is protocol version 2 and carries the `ui_request` / `ui_notice` / `ui_cancel` / `ui_response` frames plus `login` / `login_cancel`, and validates every frame in both directions. `piSdkEventMapper` translates Pi SDK events into `AgentChatEvent`s and owns the card translation helpers (`piUiRequestToPendingInput`, `piUiResponseFromAnswer`, `piUiNoticeToChatEvents`, `piExtensionLoadNotice`). | | `apps/desktop/src/main/services/chat/piSdkUiBridge.ts` | Worker-side half of the UI channel, deliberately free of Pi imports. Funnels Pi's three unrelated callback APIs — `AuthInteraction`, custom-tool `execute`, and an extension's `ExtensionUIContext` — into one never-rejecting `request()` that resolves to `null` when a card is dismissed, a turn aborts, or the worker is disposed. Also builds ADE's `ask_user` tool, the per-tool-call approval gate, and the extension UI context. | | `apps/desktop/src/main/services/ai/piInstallation.ts` | Resolves the user's Pi installation: CLI path, SDK package root/entry, agent dir, `auth.json` / models / settings paths, provider inventory, and a `blocker` string when the SDK cannot be used (missing package, or a Node older than `PI_SDK_MIN_NODE`). `sdkAvailable` and `cliAvailable` are independent — the CLI can be present while the SDK path is blocked. |