diff --git a/apps/desktop/src/main/acp-host.mjs b/apps/desktop/src/main/acp-host.mjs index 60d93101..9a24153b 100644 --- a/apps/desktop/src/main/acp-host.mjs +++ b/apps/desktop/src/main/acp-host.mjs @@ -30,6 +30,11 @@ const AUTH_REQUIRED_CODE = -32000; const MAX_ATTACHMENTS = 8; const MAX_ATTACHMENT_CHARS = 8_000_000; const HANDSHAKE_TIMEOUT_MS = 20_000; +const WARM_SESSION_LIMIT = 2; +const WARM_IDLE_MS = 10 * 60_000; +const WARM_CANCEL_GRACE_MS = 5_000; +const HISTORY_TEXT_LIMIT = 16_000; +const SESSION_KEY_PATTERN = /^[A-Za-z0-9_-]{1,80}$/; const WORKSPACE_MARKER = `${path.sep}edgeever-acp-`; const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); @@ -365,11 +370,14 @@ const rejectAttachment = (attachment, reason) => ({ reason, }); -export function buildPromptContent({ prompt, contextText, attachments, promptCapabilities = {}, promptFirst = false } = {}) { +export function buildPromptContent({ prompt, contextText, historyText, attachments, promptCapabilities = {}, promptFirst = false } = {}) { const blocks = []; const rejectedAttachments = []; + const contextBlocks = []; + if (typeof contextText === "string" && contextText.length > 0) contextBlocks.push({ type: "text", text: contextText }); + if (typeof historyText === "string" && historyText.length > 0) contextBlocks.push({ type: "text", text: historyText }); if (promptFirst) blocks.push({ type: "text", text: typeof prompt === "string" ? prompt : "" }); - if (typeof contextText === "string" && contextText.length > 0) blocks.push({ type: "text", text: contextText }); + blocks.push(...contextBlocks); if (!promptFirst) blocks.push({ type: "text", text: typeof prompt === "string" ? prompt : "" }); const list = Array.isArray(attachments) ? attachments : []; list.forEach((attachment, index) => { @@ -740,7 +748,8 @@ const createEdgeEverAcpClient = (requestId, emit, allowPermissions, onAuthStatus return allowPermissions ? automaticAcpPermissionResponse(params) : { outcome: { outcome: "cancelled" } }; }, sessionUpdate(params) { - for (const event of eventsFromSessionUpdate(requestId, params)) emit(event); + const id = typeof requestId === "function" ? requestId() : requestId; + for (const event of eventsFromSessionUpdate(id, params)) emit(event); }, readTextFile() { throw new Error("filesystem access is not available"); @@ -827,6 +836,11 @@ export function createAcpHostRuntime(options = {}) { const commandDeps = options; const manager = options.adapterManager ?? (options.adapterStore ? createAcpAdapterManager({ root: options.adapterStore, executablePath: options.executablePath, fetchImpl: options.adapterFetchImpl }) : null); const active = new Map(); + const warmSessions = new Map(); + const preparing = new Map(); + const warmLimit = Number.isInteger(options.warmSessionLimit) && options.warmSessionLimit > 0 ? options.warmSessionLimit : WARM_SESSION_LIMIT; + const warmIdleMs = Number.isInteger(options.warmIdleMs) && options.warmIdleMs >= 0 ? options.warmIdleMs : WARM_IDLE_MS; + const warmCancelGraceMs = Number.isInteger(options.warmCancelGraceMs) && options.warmCancelGraceMs >= 0 ? options.warmCancelGraceMs : WARM_CANCEL_GRACE_MS; const updateFailures = new Map(); const installingIds = new Set(); const latestStatus = new Map(); @@ -854,6 +868,11 @@ export function createAcpHostRuntime(options = {}) { }; const connect = async (command, requestId, emit, signal, authMethodId, mcpServers = [], allowPermissions = false, workBuddyAuth = null, verifyWorkBuddy = false) => { + const sink = emit && typeof emit === "object" && typeof emit.emit === "function" + ? emit + : { emit: typeof emit === "function" ? emit : () => {} }; + const notifyConnection = (event) => sink.emit(event); + const liveRequestId = () => (typeof requestId === "function" ? requestId() : requestId); const cwd = await createAcpWorkspace(mkdtempImpl, options.tmpRoot); let child = null; let authMethods = []; @@ -876,7 +895,7 @@ export function createAcpHostRuntime(options = {}) { // Correlate transient Windows consoles with adapter connections without // logging command arguments, environment variables or credential paths. const processDiagnostic = (event, details = {}) => { - try { options.onProcessDiagnostic?.({ event, requestId, pid: child.pid, ...details }); } catch { /* diagnostics are optional */ } + try { options.onProcessDiagnostic?.({ event, requestId: liveRequestId(), pid: child.pid, ...details }); } catch { /* diagnostics are optional */ } }; processDiagnostic("spawned"); child.once("exit", (code, signal) => processDiagnostic("exited", { code, signal })); @@ -910,7 +929,7 @@ export function createAcpHostRuntime(options = {}) { }); child.stderr?.on("end", () => { recordStderr(stderrBuffer); stderrBuffer = ""; }); const stream = acpNdJsonStream(child); - const connection = new ClientSideConnection(() => createEdgeEverAcpClient(requestId, emit, allowPermissions, onAuthStatus), stream); + const connection = new ClientSideConnection(() => createEdgeEverAcpClient(liveRequestId, notifyConnection, allowPermissions, onAuthStatus), stream); void connection.closed?.catch(() => {}); const initialized = await connection.initialize(acpInitializeParams(version)); authMethods = publicAuthMethods(initialized); @@ -988,7 +1007,106 @@ export function createAcpHostRuntime(options = {}) { } }; + const sessionKeyOf = (input) => { + if (input.sessionKey == null || input.sessionKey === "") return null; + if (typeof input.sessionKey !== "string" || !SESSION_KEY_PATTERN.test(input.sessionKey)) { + throw new Error("invalid_session"); + } + return input.sessionKey; + }; + const commandFingerprint = (command) => ( + typeof command === "string" ? command : `${command?.command ?? ""}\0${(command?.args ?? []).join("\0")}` + ); + const discardConnected = async (connected, mcpBridge) => { + const stopped = connected?.stop?.() ?? Promise.resolve(); + const closedBridge = mcpBridge?.close?.() ?? Promise.resolve(); + try { await stopped; } catch { /* The process may already be gone. */ } + if (connected?.cwd) await cleanupWorkspace(connected.cwd); + try { await closedBridge; } catch { /* The bridge may already be closed. */ } + }; + const retireWarm = async (warm) => { + if (!warm || warm.retired) return; + warm.retired = true; + warm.busy = false; + clearTimeout(warm.idleTimer); + for (const [key, value] of warmSessions) if (value === warm) warmSessions.delete(key); + await discardConnected(warm, warm.mcpBridge); + }; + const armIdle = (warm) => { + clearTimeout(warm.idleTimer); + warm.lastUsed = Date.now(); + if (!(warmIdleMs > 0)) return; + warm.idleTimer = setTimeout(() => { + if (warm.busy) armIdle(warm); + else void retireWarm(warm); + }, warmIdleMs); + warm.idleTimer.unref?.(); + }; + const retireSiblingSessions = async (keepKey, sessionKey) => { + const suffix = `\0${sessionKey}`; + const siblings = [...warmSessions.entries()].filter(([key, warm]) => key !== keepKey && key.endsWith(suffix) && !warm.busy && !warm.retired); + for (const [, warm] of siblings) await retireWarm(warm); + }; + // One handshake is shared by a sidebar prepare and the first prompt. The + // session stays up for later turns instead of spawning a process per message. + const startWarmSession = (key, spec) => { + const requestRef = { id: "", open: false, notify: () => {} }; + const job = (async () => { + await ensureWarmCapacity(); + let mcpBridge = null; + try { + if (spec.wantsNotes && spec.access) mcpBridge = await (options.startMcpBridge ?? startAcpMcpBridge)(spec.access); + const sink = { emit: (event) => { if (requestRef.open) requestRef.notify(event); } }; + const connected = await withHandshakeTimeout((signal) => connect( + spec.command, () => requestRef.id, sink, signal, undefined, + mcpBridge ? [mcpServerFor(mcpBridge)] : [], true, null, spec.verifyWorkBuddy, + )); + spec.rememberPromptStatus({ + state: "available", + authMethods: connected.authMethods, + promptCapabilities: connected.promptCapabilities, + }); + const warm = { + ...connected, + requestRef, + mcpBridge, + busy: false, + prompted: false, + retired: false, + lastUsed: Date.now(), + idleTimer: null, + }; + warmSessions.set(key, warm); + connected.child?.once("exit", () => { void retireWarm(warm); }); + armIdle(warm); + return warm; + } catch (error) { + const failure = classifyAcpFailure(error); + spec.rememberPromptStatus({ + ...failureFields(failure), + ...(failure.state === "needs_login" ? { authMethods: error.authMethods ?? [] } : {}), + }); + try { await mcpBridge?.close(); } catch { /* The bridge may already be closed. */ } + throw error; + } + })(); + preparing.set(key, job); + job.finally(() => { if (preparing.get(key) === job) preparing.delete(key); }).catch(() => {}); + return job; + }; + const ensureWarmCapacity = async () => { + while (warmSessions.size >= warmLimit) { + const idle = [...warmSessions.values()].filter((warm) => !warm.busy && !warm.retired); + if (!idle.length) return; + idle.sort((left, right) => left.lastUsed - right.lastUsed); + await retireWarm(idle[0]); + } + }; + return { + async releaseWarmSessions() { + await Promise.all([...warmSessions.values()].map((warm) => retireWarm(warm))); + }, clearAdapterStatus(id) { latestStatus.delete(id); }, listAdapters(configuredPaths = {}) { return ["codex", "claudeCode", "antigravity", "openClaw", "hermesAgent", "grokBuild", "deepseekHarness", "piAgent", "workbuddyCn", "workbuddyIntl"].map((id) => { @@ -1136,6 +1254,59 @@ export function createAcpHostRuntime(options = {}) { } }, + // Open the selected chat's agent before the user sends. Every desktop + // client uses this; it is not tied to one machine or one agent. + async prepare(input) { + if (!input || typeof input !== "object" || !Object.hasOwn(ADAPTERS, input.adapterId)) return { ready: false }; + let sessionKey; + try { sessionKey = sessionKeyOf(input); } catch { return { ready: false }; } + if (!sessionKey) return { ready: false }; + const resolved = resolveCommand({ id: input.adapterId, path: input.path }); + if (!resolved.ok) return { ready: false }; + const customPath = input.adapterId === "antigravity" && typeof input.path === "string" ? input.path.trim() : ""; + const rememberPromptStatus = (fields) => latestStatus.set(input.adapterId, { + ...adapterShell(input.adapterId), + ...fields, + ...(customPath ? { customPath } : {}), + }); + const wantsNotes = Boolean(options.mcpAccess) && input.noteAccess !== false && input.adapterId !== "openClaw"; + let access = null; + if (wantsNotes) { + try { access = await options.mcpAccess(); } catch { return { ready: false }; } + } + const key = [ + input.adapterId, + commandFingerprint(resolved.command), + customPath, + wantsNotes ? "notes" : "isolated", + wantsNotes ? `${access?.baseUrl ?? ""}\0${access?.accountId ?? ""}` : "", + sessionKey, + ].join("\0"); + let warm = warmSessions.get(key) ?? null; + if (warm && (warm.retired || warm.child?.exitCode != null || warm.child?.signalCode != null)) { + await retireWarm(warm); + warm = null; + } + if (warm?.busy || warm) { + if (warm && !warm.busy) armIdle(warm); + return { ready: true }; + } + try { + await (preparing.get(key) ?? startWarmSession(key, { + command: resolved.command, + wantsNotes, + access, + verifyWorkBuddy: input.adapterId === "workbuddyCn" || input.adapterId === "workbuddyIntl", + rememberPromptStatus, + })); + return { ready: true }; + } catch { + return { ready: false }; + } + }, + + // Sidebar chats reuse one process and ACP session. Follow-up turns skip + // startup and the replayed transcript. Calls without sessionKey stay one-shot. async prompt(input, emit = () => {}) { if (!input || typeof input !== "object" || typeof input.prompt !== "string") throw new Error("invalid_prompt"); if (!Object.hasOwn(ADAPTERS, input.adapterId)) throw new Error("unknown_adapter"); @@ -1145,6 +1316,12 @@ export function createAcpHostRuntime(options = {}) { notify({ requestId, type: "error", message }); return { requestId }; }; + let sessionKey; + try { + sessionKey = sessionKeyOf(input); + } catch { + return fail("invalid_session"); + } const resolved = resolveCommand({ id: input.adapterId, path: input.path }); if (!resolved.ok) return fail(promptFailureMessage(resolved)); const customPath = input.adapterId === "antigravity" && typeof input.path === "string" ? input.path.trim() : ""; @@ -1153,91 +1330,174 @@ export function createAcpHostRuntime(options = {}) { ...fields, ...(customPath ? { customPath } : {}), }); - - let connected; - let mcpBridge; - try { - // OpenClaw's ACP Gateway bridge rejects session-scoped MCP servers. - if (options.mcpAccess && input.noteAccess !== false && input.adapterId !== "openClaw") { - const access = await options.mcpAccess(); - mcpBridge = await (options.startMcpBridge ?? startAcpMcpBridge)(access); + // OpenClaw's ACP Gateway bridge rejects session-scoped MCP servers. + const wantsNotes = Boolean(options.mcpAccess) && input.noteAccess !== false && input.adapterId !== "openClaw"; + let access = null; + if (wantsNotes) { + try { + access = await options.mcpAccess(); + } catch (error) { + return fail(promptFailureMessage(classifyAcpFailure(error))); } - } catch (error) { - return fail(promptFailureMessage(classifyAcpFailure(error))); } - try { - connected = await withHandshakeTimeout((signal) => connect( - resolved.command, requestId, notify, signal, undefined, - mcpBridge ? [mcpServerFor(mcpBridge)] : [], true, null, input.adapterId === "workbuddyCn" || input.adapterId === "workbuddyIntl", - )); - rememberPromptStatus({ - state: "available", - authMethods: connected.authMethods, - promptCapabilities: connected.promptCapabilities, + const accountKey = wantsNotes ? `${access?.baseUrl ?? ""}\0${access?.accountId ?? ""}` : ""; + const key = sessionKey ? [ + input.adapterId, + commandFingerprint(resolved.command), + customPath, + wantsNotes ? "notes" : "isolated", + accountKey, + sessionKey, + ].join("\0") : null; + let warm = key ? warmSessions.get(key) ?? null : null; + if (warm && (warm.retired || warm.child?.exitCode != null || warm.child?.signalCode != null)) { + await retireWarm(warm); + warm = null; + } + if (warm?.busy) return fail("session_busy"); + // A read-only turn must not continue inside the note-writing process. + // Dropping the other process makes the next normal turn replay the transcript. + if (key) await retireSiblingSessions(key, sessionKey); + + const verifyWorkBuddy = input.adapterId === "workbuddyCn" || input.adapterId === "workbuddyIntl"; + const claim = (session) => { + session.requestRef.id = requestId; + session.requestRef.open = true; + session.requestRef.notify = notify; + session.busy = true; + clearTimeout(session.idleTimer); + }; + if (!warm && key) { + const pending = preparing.get(key) ?? startWarmSession(key, { + command: resolved.command, wantsNotes, access, verifyWorkBuddy, rememberPromptStatus, }); - } catch (error) { - const failure = classifyAcpFailure(error); - rememberPromptStatus({ - ...failureFields(failure), - ...(failure.state === "needs_login" ? { authMethods: error.authMethods ?? [] } : {}), - }); - await mcpBridge?.close(); - return fail(promptFailureMessage(failure)); + try { + warm = await pending; + } catch (error) { + return fail(promptFailureMessage(classifyAcpFailure(error))); + } + if (warm.retired || warm.child?.exitCode != null || warm.child?.signalCode != null) { + await retireWarm(warm); + return fail("connection_failed"); + } + if (warm.busy) return fail("session_busy"); + } + let connected = warm; + let mcpBridge = warm?.mcpBridge ?? null; + // prompted is set only after a turn is delivered. Preparing a process + // still sends the transcript with the first real question. + const resumeConversation = warm?.prompted === true; + if (warm) { + claim(warm); + try { options.onProcessDiagnostic?.({ event: resumeConversation ? "reused" : "ready", requestId, pid: warm.child?.pid }); } catch { /* diagnostics are optional */ } + } else { + try { + if (wantsNotes && access) mcpBridge = await (options.startMcpBridge ?? startAcpMcpBridge)(access); + } catch (error) { + return fail(promptFailureMessage(classifyAcpFailure(error))); + } + const spawnedRequest = { id: requestId, open: true, notify }; + const sink = { emit: (event) => { if (spawnedRequest.open) spawnedRequest.notify(event); } }; + try { + connected = await withHandshakeTimeout((signal) => connect( + resolved.command, () => spawnedRequest.id, sink, signal, undefined, + mcpBridge ? [mcpServerFor(mcpBridge)] : [], true, null, verifyWorkBuddy, + )); + rememberPromptStatus({ + state: "available", + authMethods: connected.authMethods, + promptCapabilities: connected.promptCapabilities, + }); + } catch (error) { + const failure = classifyAcpFailure(error); + rememberPromptStatus({ + ...failureFields(failure), + ...(failure.state === "needs_login" ? { authMethods: error.authMethods ?? [] } : {}), + }); + try { await mcpBridge?.close(); } catch { /* The bridge may already be closed. */ } + return fail(promptFailureMessage(failure)); + } + connected.requestRef = spawnedRequest; } + const requestRef = warm ? warm.requestRef : connected.requestRef; let content; try { + const historyText = !resumeConversation && typeof input.historyText === "string" ? input.historyText.slice(-HISTORY_TEXT_LIMIT) : ""; content = buildPromptContent({ prompt: input.prompt, contextText: input.contextText, + historyText, attachments: input.attachments, promptCapabilities: connected.promptCapabilities, // Codex derives its thread title from the start of the first user turn. promptFirst: input.adapterId === "codex", }); - if (mcpBridge) { + if (mcpBridge && !resumeConversation) { content.blocks.splice(input.adapterId === "codex" ? 1 : 0, 0, { type: "text", text: "For EdgeEver note operations in this conversation, use only the session-provided MCP server named edgeever-current-workspace. It is connected to the account currently signed in to EdgeEver. Ignore any EdgeEver MCP server from your persistent configuration, which may target a different instance or account. For a request to generate a poster or 海报笔记, first call get_poster_capabilities and read templates and browserDesignTemplates. Select by zhUse/enUse, the requested style and target platform (fit). Respect a named template. Use a basic templates ID for create_poster_memo.template. Full browserDesignTemplates IDs go only in request_poster_render.templateId, even when IDs overlap. For a new poster, apply the chosen full design during initial rendering if appropriate; an existing poster requires an explicit template-change request. With sections, basic styles share grid/list layout and differ mainly in palette. Then use create_poster_memo with concise copy; it produces the actual editable poster type. Never manually encode poster source, inspect local files to discover the format, or substitute poster-style Markdown. If the tool is unavailable or fails, report that clearly. To query posters, use search_memos or list_memos with poster=true rather than searching the title for 海报. For detailed design, read get_poster_capabilities for valid fields, fonts, platform sizes and operations. Read and edit a poster with get_poster then update_poster. apply_template replaces the layout and must be requested. get_poster_preview and export_poster provide saved previews and editable source, not fresh rendering. Request new PNG rendering or a full browser template with request_poster_render and expectedRevision; an online desktop/Web client handles it. Query get_poster_render_job no faster than every 5 seconds; only completed means rendered, then get_poster_preview. Pending means waiting for a client; report that honestly. Delete exact queried IDs with trash_memos; restore them with restore_memos. When you create a note, include a clickable Markdown link to it in your final answer: [note title](#memo=exact_memo_id_from_tool_result). Do not show the note ID as plain text or use an https://edgeever.ai/memo/ URL; that URL opens an external website instead of the current workspace.", }); } } catch (error) { - await connected.stop(); - await cleanupWorkspace(connected.cwd); - await mcpBridge?.close(); + requestRef.open = false; + if (warm) { + warm.busy = false; + armIdle(warm); + } else { + await discardConnected(connected, mcpBridge); + } return fail(promptFailureMessage(classifyAcpFailure(error))); } + let resolveSettled; + const whenSettled = new Promise((resolve) => { resolveSettled = resolve; }); const session = { cancelled: false, settled: false, - ...connected, + keep: Boolean(warm), + warm, + connected, mcpBridge, + connection: connected.connection, + sessionId: connected.sessionId, + requestRef, + whenSettled, + stop: connected.stop, }; active.set(requestId, session); - const finish = (event) => { + const finish = (event, { drop }) => { if (session.settled) return; session.settled = true; + requestRef.open = false; notify(event); active.delete(requestId); - void session.stop().then(() => cleanupWorkspace(session.cwd)); - void session.mcpBridge?.close(); + if (warm && !drop && !warm.retired) { + warm.busy = false; + armIdle(warm); + } else if (warm) { + void retireWarm(warm); + } else { + void discardConnected(connected, mcpBridge); + } + resolveSettled(); }; + if (warm) warm.prompted = true; void connected.connection.prompt({ sessionId: connected.sessionId, prompt: content.blocks }).then((result) => { - if (session.cancelled) return finish({ requestId, type: "done" }); + if (session.cancelled) return finish({ requestId, type: "done" }, { drop: false }); const failure = connected.authenticationRequired() ? "needs_login" : promptResultFailure(result); if (failure === "needs_login") { rememberPromptStatus({ state: "needs_login", authMethods: connected.authMethods }); } - finish(failure ? { requestId, type: "error", message: failure } : { requestId, type: "done" }); + finish(failure ? { requestId, type: "error", message: failure } : { requestId, type: "done" }, { drop: Boolean(failure) }); }).catch((error) => { - if (session.cancelled) finish({ requestId, type: "done" }); + if (session.cancelled) finish({ requestId, type: "done" }, { drop: warm?.retired === true }); else { const failure = connected.authenticationRequired() ? { state: "needs_login" } : classifyAcpFailure(error); if (failure.state === "needs_login") { rememberPromptStatus({ state: "needs_login", authMethods: connected.authMethods }); } - finish({ requestId, type: "error", message: promptFailureMessage(failure) }); + finish({ requestId, type: "error", message: promptFailureMessage(failure) }, { drop: true }); } }); return { requestId, rejectedAttachments: content.rejectedAttachments }; @@ -1250,7 +1510,15 @@ export function createAcpHostRuntime(options = {}) { try { if (session.connection && session.sessionId) await session.connection.cancel({ sessionId: session.sessionId }); } catch { - // The process may already be gone. Killing it is enough. + // The process may already be gone. Killing it is enough for a one-shot call. + } + if (session.keep) { + let timer; + const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), warmCancelGraceMs); }); + const outcome = await Promise.race([session.whenSettled.then(() => "settled"), timedOut]); + clearTimeout(timer); + if (outcome === "timeout") await retireWarm(session.warm); + return { ok: true }; } await session.stop(); void session.mcpBridge?.close(); @@ -1312,6 +1580,10 @@ export function registerAcpIpc(ipcMain, runtime = createAcpHostRuntime(), { if (!allowInstall(event.sender, event)) throw new Error("adapter_auth_forbidden"); return runtime.authenticateAdapter(await withRecordedPath(input)); }); + ipcMain.handle("desktop:acp-prepare", async (event, input) => { + if (!allowPrompt(event.sender, event)) throw new Error("acp_prepare_forbidden"); + return runtime.prepare(await withRecordedPath(input)); + }); ipcMain.handle("desktop:acp-prompt", async (event, input) => { if (!allowPrompt(event.sender, event)) throw new Error("acp_prompt_forbidden"); return runtime.prompt(await withRecordedPath(input), (acpEvent) => send(event.sender, acpEvent)); diff --git a/apps/desktop/src/main/acp-host.test.mjs b/apps/desktop/src/main/acp-host.test.mjs index 089883ea..c5391d58 100644 --- a/apps/desktop/src/main/acp-host.test.mjs +++ b/apps/desktop/src/main/acp-host.test.mjs @@ -88,13 +88,14 @@ const collector = () => { const fakeAgentSource = ({ reportPath, secretPath, allowImage, allowEmbedded, hold, requireAuth, advertiseAuth, promptRefusal, hangAuth, hangSession, failSessionBeforeLogin, workBuddyAccountApi, workBuddyLoginFailure, workBuddyUnreadable, workBuddyUnreadableAfterLogin, authMethodId = "browser", authStatusKind, advertiseAuthStatus = false, authStatusDelay = 20, stderrText, promptAuthStatusKind }) => `#!/usr/bin/env bun import * as acp from ${JSON.stringify(sdkHref)}; -import { writeFileSync, readFileSync } from "node:fs"; +import { writeFileSync, readFileSync, appendFileSync } from "node:fs"; const reportPath = ${JSON.stringify(reportPath)}; const secretPath = ${JSON.stringify(secretPath)}; const allowImage = ${allowImage ? "true" : "false"}; const allowEmbedded = ${allowEmbedded ? "true" : "false"}; const hold = ${hold ? "true" : "false"}; +let held = false; const requireAuth = ${requireAuth ? "true" : "false"}; const advertiseAuth = ${advertiseAuth ? "true" : "false"}; const promptRefusal = ${promptRefusal ? JSON.stringify(promptRefusal) : "null"}; @@ -197,6 +198,8 @@ acp.agent({ name: "edgeever-fake-agent" }) .onRequest("session/new", (ctx) => { if (${failSessionBeforeLogin ? "true" : "false"} && !authenticated) throw new acp.RequestError(-32603, "connection_failed"); if (requireAuth && !authenticated) throw new acp.RequestError(-32000, "auth_required"); + report.sessionCount = (report.sessionCount ?? 0) + 1; + appendFileSync(reportPath + ".sessions", String(process.pid) + "\\n"); report.newSession = { cwd: ctx.params.cwd, mcpServers: ctx.params.mcpServers }; save(); if (${hangSession ? "true" : "false"}) return new Promise(() => {}); @@ -206,6 +209,7 @@ acp.agent({ name: "edgeever-fake-agent" }) if (${promptAuthStatusKind ? "true" : "false"}) { await ctx.client.notify("_auth/status_update", { authStatus: { kind: ${JSON.stringify(promptAuthStatusKind ?? "none")}, label: "Fixture" } }); } + report.prompts = [...(report.prompts ?? []), ctx.params.prompt]; report.prompt = ctx.params.prompt; save(); if (promptRefusal) return promptRefusal; @@ -221,7 +225,7 @@ acp.agent({ name: "edgeever-fake-agent" }) sessionId: ctx.params.sessionId, update: { sessionUpdate: "tool_call", toolCallId: "call_1", name: "search", title: "Search notes", status: "pending" }, }); - if (!hold) { + if (!(hold && !held)) { try { const result = await ctx.client.request("fs/read_text_file", { sessionId: ctx.params.sessionId, path: secretPath }); report.readResult = result?.content ?? result ?? null; @@ -237,6 +241,7 @@ acp.agent({ name: "edgeever-fake-agent" }) save(); return { stopReason: "end_turn" }; } + held = true; await released; return { stopReason: "cancelled" }; }) @@ -253,6 +258,16 @@ const writeFakeAgent = async (directory, options) => { return scriptPath; }; +const processAlive = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + throw error; + } +}; + const waitUntilExited = async (pid) => { const started = Date.now(); while (Date.now() - started < 3000) { @@ -541,10 +556,11 @@ describe("ACP prompt blocks", () => { }); test("puts a Codex user's request before context for a useful thread title", () => { - const content = buildPromptContent({ prompt: "分析资金往来", contextText: "Current note and earlier turns", promptFirst: true }); + const content = buildPromptContent({ prompt: "分析资金往来", contextText: "Current note and earlier turns", historyText: "Earlier turns", promptFirst: true }); expect(content.blocks).toEqual([ { type: "text", text: "分析资金往来" }, { type: "text", text: "Current note and earlier turns" }, + { type: "text", text: "Earlier turns" }, ]); }); @@ -1416,6 +1432,301 @@ describe("ACP stdio session", () => { await rm(directory, { recursive: true, force: true }); } }, 15_000); + + const chat = "11111111-1111-4111-8111-111111111111"; + const otherChat = "22222222-2222-4222-8222-222222222222"; + + sessionTest("reuses one ACP process for the same chat and omits replayed history", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-warm-")); + const reportPath = path.join(directory, "report.json"); + let closed = false; + const runtime = createAcpHostRuntime({ + mcpAccess: () => ({ baseUrl: "https://notes.example", sessionToken: "login-secret", accountId: "account-a" }), + startMcpBridge: () => ({ + url: "http://127.0.0.1:12345", + secret: "temporary-secret", + close: async () => { closed = true; }, + }), + }); + try { + const scriptPath = await writeFakeAgent(directory, { + reportPath, + secretPath: path.join(directory, "secret.txt"), + allowImage: false, + allowEmbedded: false, + hold: false, + }); + const prompt = (text, events) => runtime.prompt({ + adapterId: "antigravity", + path: scriptPath, + prompt: text, + sessionKey: chat, + contextText: "Current note", + historyText: "Earlier turns", + }, events.emit); + const firstEvents = collector(); + const first = await prompt("hello", firstEvents); + await firstEvents.waitFor((event) => event.type === "done" && event.requestId === first.requestId); + const pid = Number(await readFile(`${reportPath}.pid`, "utf8")); + expect(processAlive(pid)).toBe(true); + expect(closed).toBe(false); + const secondEvents = collector(); + const second = await prompt("again", secondEvents); + const delta = await secondEvents.waitFor((event) => event.type === "text-delta"); + expect(delta).toEqual({ requestId: second.requestId, type: "text-delta", text: "hello from agent" }); + expect(secondEvents.events.some((event) => event.requestId === first.requestId)).toBe(false); + await secondEvents.waitFor((event) => event.type === "done"); + expect(Number(await readFile(`${reportPath}.pid`, "utf8"))).toBe(pid); + expect(processAlive(pid)).toBe(true); + const report = await readReport(reportPath); + expect(report.sessionCount).toBe(1); + expect(report.prompts).toHaveLength(2); + expect(JSON.stringify(report.prompts[0])).toContain("edgeever-current-workspace"); + expect(JSON.stringify(report.prompts[0])).toContain("Earlier turns"); + expect(JSON.stringify(report.prompts[1])).not.toContain("edgeever-current-workspace"); + expect(JSON.stringify(report.prompts[1])).not.toContain("Earlier turns"); + expect(JSON.stringify(report.prompts[1])).toContain("Current note"); + expect(closed).toBe(false); + await runtime.releaseWarmSessions(); + await waitUntilExited(pid); + expect(closed).toBe(true); + } finally { + await runtime.releaseWarmSessions(); + await rm(directory, { recursive: true, force: true }); + } + }, 15_000); + + sessionTest("keeps a cancelled chat process and rejects an overlapping prompt", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-warm-cancel-")); + const reportPath = path.join(directory, "report.json"); + const runtime = createAcpHostRuntime(); + try { + const scriptPath = await writeFakeAgent(directory, { + reportPath, + secretPath: path.join(directory, "secret.txt"), + allowImage: false, + allowEmbedded: false, + hold: true, + }); + const events = collector(); + const first = await runtime.prompt({ + adapterId: "antigravity", + path: scriptPath, + prompt: "wait", + sessionKey: chat, + historyText: "Earlier turns", + }, events.emit); + await events.waitFor((event) => event.type === "text-delta"); + const overlap = collector(); + const second = await runtime.prompt({ + adapterId: "antigravity", + path: scriptPath, + prompt: "too soon", + sessionKey: chat, + }, overlap.emit); + expect(await overlap.waitFor((event) => event.type === "error")).toEqual({ requestId: second.requestId, type: "error", message: "session_busy" }); + expect(await runtime.cancel(first.requestId)).toEqual({ ok: true }); + await events.waitFor((event) => event.type === "done"); + const pid = Number(await readFile(`${reportPath}.pid`, "utf8")); + expect(processAlive(pid)).toBe(true); + const continued = collector(); + const third = await runtime.prompt({ + adapterId: "antigravity", + path: scriptPath, + prompt: "continue", + sessionKey: chat, + historyText: "Earlier turns", + }, continued.emit); + expect((await continued.waitFor((event) => event.type === "text-delta")).requestId).toBe(third.requestId); + await continued.waitFor((event) => event.type === "done"); + const report = await readReport(reportPath); + expect(report.sessionCount).toBe(1); + expect(report.prompts).toHaveLength(2); + expect(JSON.stringify(report.prompts[1])).not.toContain("Earlier turns"); + expect(processAlive(pid)).toBe(true); + } finally { + await runtime.releaseWarmSessions(); + await rm(directory, { recursive: true, force: true }); + } + }, 15_000); + + sessionTest("starts a separate process for another chat or another account", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-warm-reset-")); + const reportPath = path.join(directory, "report.json"); + let accountId = "account-a"; + const runtime = createAcpHostRuntime({ + warmSessionLimit: 3, + mcpAccess: () => ({ baseUrl: "https://notes.example", sessionToken: "login-secret", accountId }), + startMcpBridge: () => ({ url: "http://127.0.0.1:12345", secret: "temporary-secret", close: async () => {} }), + }); + try { + const scriptPath = await writeFakeAgent(directory, { + reportPath, + secretPath: path.join(directory, "secret.txt"), + allowImage: false, + allowEmbedded: false, + hold: false, + }); + const send = async (sessionKey) => { + const events = collector(); + await runtime.prompt({ + adapterId: "antigravity", + path: scriptPath, + prompt: "hello", + sessionKey, + historyText: "Earlier turns", + }, events.emit); + await events.waitFor((event) => event.type === "done"); + return Number(await readFile(`${reportPath}.pid`, "utf8")); + }; + const firstPid = await send(chat); + const secondPid = await send(otherChat); + expect(secondPid).not.toBe(firstPid); + expect(processAlive(firstPid)).toBe(true); + expect(processAlive(secondPid)).toBe(true); + accountId = "account-b"; + const switchedPid = await send(chat); + expect(switchedPid).not.toBe(firstPid); + expect(processAlive(firstPid)).toBe(false); + expect(processAlive(secondPid)).toBe(true); + expect(JSON.stringify((await readReport(reportPath)).prompts.at(-1))).toContain("Earlier turns"); + } finally { + await runtime.releaseWarmSessions(); + await rm(directory, { recursive: true, force: true }); + } + }, 15_000); + + sessionTest("starts over after the chat process has been idle", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-warm-idle-")); + const reportPath = path.join(directory, "report.json"); + const runtime = createAcpHostRuntime({ warmIdleMs: 80 }); + try { + const scriptPath = await writeFakeAgent(directory, { + reportPath, + secretPath: path.join(directory, "secret.txt"), + allowImage: false, + allowEmbedded: false, + hold: false, + }); + const send = async () => { + const events = collector(); + await runtime.prompt({ + adapterId: "antigravity", + path: scriptPath, + prompt: "hello", + sessionKey: chat, + historyText: "Earlier turns", + }, events.emit); + await events.waitFor((event) => event.type === "done"); + return Number(await readFile(`${reportPath}.pid`, "utf8")); + }; + const firstPid = await send(); + await new Promise((resolve) => setTimeout(resolve, 200)); + await waitUntilExited(firstPid); + const secondPid = await send(); + expect(secondPid).not.toBe(firstPid); + expect(JSON.stringify((await readReport(reportPath)).prompt)).toContain("Earlier turns"); + } finally { + await runtime.releaseWarmSessions(); + await rm(directory, { recursive: true, force: true }); + } + }, 15_000); + + sessionTest("a read-only turn does not continue inside the note-writing process", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-warm-readonly-")); + const reportPath = path.join(directory, "report.json"); + const runtime = createAcpHostRuntime({ + mcpAccess: () => ({ baseUrl: "https://notes.example", sessionToken: "login-secret", accountId: "account-a" }), + startMcpBridge: () => ({ url: "http://127.0.0.1:12345", secret: "temporary-secret", close: async () => {} }), + }); + try { + const scriptPath = await writeFakeAgent(directory, { + reportPath, + secretPath: path.join(directory, "secret.txt"), + allowImage: false, + allowEmbedded: false, + hold: false, + }); + const send = async ({ noteAccess = true } = {}) => { + const events = collector(); + await runtime.prompt({ + adapterId: "antigravity", + path: scriptPath, + prompt: "hello", + sessionKey: chat, + historyText: "Earlier turns", + noteAccess, + }, events.emit); + await events.waitFor((event) => event.type === "done"); + return Number(await readFile(`${reportPath}.pid`, "utf8")); + }; + const writingPid = await send(); + const readingPid = await send({ noteAccess: false }); + expect(readingPid).not.toBe(writingPid); + expect(processAlive(writingPid)).toBe(false); + expect(JSON.stringify((await readReport(reportPath)).newSession.mcpServers)).toBe("[]"); + const resumedPid = await send(); + expect(resumedPid).not.toBe(readingPid); + expect(processAlive(readingPid)).toBe(false); + expect(JSON.stringify((await readReport(reportPath)).prompt)).toContain("Earlier turns"); + expect(JSON.stringify((await readReport(reportPath)).newSession.mcpServers)).toContain("edgeever-current-workspace"); + } finally { + await runtime.releaseWarmSessions(); + await rm(directory, { recursive: true, force: true }); + } + }, 15_000); + + sessionTest("starts one ACP process for a prepared chat and keeps it for the next turn", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "edgeever-acp-prepare-")); + const reportPath = path.join(directory, "report.json"); + const runtime = createAcpHostRuntime(); + try { + const scriptPath = await writeFakeAgent(directory, { + reportPath, + secretPath: path.join(directory, "secret.txt"), + allowImage: false, + allowEmbedded: false, + hold: false, + }); + const input = { + adapterId: "antigravity", + path: scriptPath, + sessionKey: chat, + historyText: "Earlier turns", + noteAccess: false, + }; + const preparing = runtime.prepare(input); + const events = collector(); + const sent = runtime.prompt({ ...input, prompt: "hello" }, events.emit); + expect(await preparing).toEqual({ ready: true }); + await sent; + await events.waitFor((event) => event.type === "done"); + expect((await readFile(`${reportPath}.sessions`, "utf8")).trim().split("\n")).toEqual([ + String(Number(await readFile(`${reportPath}.pid`, "utf8"))), + ]); + const report = await readReport(reportPath); + expect(report.sessionCount).toBe(1); + expect(JSON.stringify(report.prompt)).toContain("Earlier turns"); + const again = collector(); + await runtime.prompt({ ...input, prompt: "next" }, again.emit); + await again.waitFor((event) => event.type === "done"); + const continued = await readReport(reportPath); + expect(continued.sessionCount).toBe(1); + expect(continued.prompts).toHaveLength(2); + expect(JSON.stringify(continued.prompts[1])).not.toContain("Earlier turns"); + expect((await readFile(`${reportPath}.sessions`, "utf8")).trim().split("\n")).toHaveLength(1); + } finally { + await runtime.releaseWarmSessions(); + await rm(directory, { recursive: true, force: true }); + } + }, 15_000); + + sessionTest("rejects a session key that is not a chat id before starting an agent", async () => { + const runtime = createAcpHostRuntime(); + const events = collector(); + const result = await runtime.prompt({ adapterId: "codex", prompt: "hello", sessionKey: "../secret" }, events.emit); + expect(await events.waitFor((event) => event.type === "error")).toEqual({ requestId: result.requestId, type: "error", message: "invalid_session" }); + }); }); test("desktop preload and main process expose the ACP host", async () => { @@ -1425,16 +1736,18 @@ test("desktop preload and main process expose the ACP host", async () => { Bun.file(new URL("./acp-host.mjs", import.meta.url)).text(), ]); expect(main).toContain("registerAcpIpc(ipcMain, createAcpHostRuntime({"); + expect(main).toContain("await releaseDesktopAcpSessions().catch(() => {});"); expect(preload).toContain('installAcpAdapter: (id) => ipcRenderer.invoke("desktop:acp-install", id)'); expect(preload).toContain('authenticateAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-authenticate", input)'); expect(host).toContain('ipcMain.handle("desktop:acp-prompt"'); expect(preload).toContain('listAcpAdapters: () => ipcRenderer.invoke("desktop:acp-list")'); expect(preload).toContain('probeAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-probe", input)'); + expect(preload).toContain('prepareAcp: (input) => ipcRenderer.invoke("desktop:acp-prepare", input)'); expect(preload).toContain('promptAcp: (input) => ipcRenderer.invoke("desktop:acp-prompt", input)'); expect(preload).toContain('cancelAcp: (requestId) => ipcRenderer.invoke("desktop:acp-cancel", requestId)'); expect(preload).toContain('ipcRenderer.on("desktop:acp-event"'); const handlers = new Map(); registerAcpIpc({ handle: (channel, handler) => handlers.set(channel, handler) }); - expect([...handlers.keys()]).toEqual(["desktop:acp-list", "desktop:acp-choose-path", "desktop:acp-probe", "desktop:acp-install", "desktop:acp-authenticate", "desktop:acp-prompt", "desktop:acp-cancel"]); + expect([...handlers.keys()]).toEqual(["desktop:acp-list", "desktop:acp-choose-path", "desktop:acp-probe", "desktop:acp-install", "desktop:acp-authenticate", "desktop:acp-prepare", "desktop:acp-prompt", "desktop:acp-cancel"]); await expect(handlers.get("desktop:acp-prompt")({ sender: {} }, { prompt: "test" })).rejects.toThrow("acp_prompt_forbidden"); }); diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index e2255eb9..30fbe800 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -146,6 +146,7 @@ let windowsDownloadedUpdateVerified = false; let sidecarScopeKey = "anonymous"; let activeAccountId = null; let shutdownCleanupStarted = false; +let releaseDesktopAcpSessions = async () => {}; let sidecarRestartTimer = null; let sidecarRestartAttempts = 0; let sidecarRestartInFlight = false; @@ -1873,6 +1874,7 @@ const startApplication = async () => { return validateTrustedAcpPath(selection.filePaths[0], { excludedRoot: app.getPath("userData") }); }, }); + releaseDesktopAcpSessions = () => acpRuntime.releaseWarmSessions(); await acpRuntime.pruneAdapters().catch(() => {}); const refreshAdapters = () => { void acpRuntime.installDetected().catch(() => []).then(() => acpRuntime.updateInstalled()).catch(() => {}); }; const firstAdapterRefresh = setTimeout(refreshAdapters, 10_000); @@ -2263,6 +2265,7 @@ app.on("before-quit", (event) => { } tray?.destroy(); void (async () => { + await releaseDesktopAcpSessions().catch(() => {}); await stopSidecar(); await unlink(crashMarkerPath()).catch(() => {}); await writeDiagnostic("session.quit"); diff --git a/apps/desktop/src/main/security-boundaries.test.mjs b/apps/desktop/src/main/security-boundaries.test.mjs index 96f7045c..5e20f8c5 100644 --- a/apps/desktop/src/main/security-boundaries.test.mjs +++ b/apps/desktop/src/main/security-boundaries.test.mjs @@ -20,6 +20,7 @@ test("ACP ignores caller paths on all execution channels and preserves main-owne listAdapters: () => [{ id: "antigravity" }], probeAdapter: (input) => calls.push(input), authenticateAdapter: (input) => calls.push(input), + prepare: (input) => calls.push(input), prompt: (input) => calls.push(input), }; const register = () => { @@ -32,7 +33,7 @@ test("ACP ignores caller paths on all execution channels and preserves main-owne }); return handlers; }; - const channels = ["desktop:acp-probe", "desktop:acp-authenticate", "desktop:acp-prompt"]; + const channels = ["desktop:acp-probe", "desktop:acp-authenticate", "desktop:acp-prepare", "desktop:acp-prompt"]; const handlers = register(); for (const channel of channels) { const input = { id: "antigravity", adapterId: "antigravity", path: "/attacker/payload" }; diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index 38f12b0d..b70020ba 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -68,6 +68,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ probeAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-probe", input), installAcpAdapter: (id) => ipcRenderer.invoke("desktop:acp-install", id), authenticateAcpAdapter: (input) => ipcRenderer.invoke("desktop:acp-authenticate", input), + prepareAcp: (input) => ipcRenderer.invoke("desktop:acp-prepare", input), promptAcp: (input) => ipcRenderer.invoke("desktop:acp-prompt", input), cancelAcp: (requestId) => ipcRenderer.invoke("desktop:acp-cancel", requestId), onAcpEvent: (callback) => { diff --git a/apps/web/src/components/ai-sidebar/AiSidebar.tsx b/apps/web/src/components/ai-sidebar/AiSidebar.tsx index 39ece075..42b101c9 100644 --- a/apps/web/src/components/ai-sidebar/AiSidebar.tsx +++ b/apps/web/src/components/ai-sidebar/AiSidebar.tsx @@ -55,6 +55,7 @@ import { AI_SIDEBAR_THREAD_KEY, AI_SIDEBAR_WIDTH_KEY, cancelDesktopAcp, + prepareDesktopAcp, promptDesktopAcp, readAiSidebarAdapter, readAiSidebarSource, @@ -1053,6 +1054,25 @@ function AiSidebarSession({ }; }, [open, busy]); + useEffect(() => { + if (!open || source !== "local") return; + let cancelled = false; + const timer = window.setTimeout(() => { + if (cancelled || locked.current) return; + const adapter = readLocalAdapter(); + if (!adapter) return; + void prepareDesktopAcp({ + adapterId: adapter.id, + ...(adapter.path ? { path: adapter.path } : {}), + sessionKey: localThreadId, + }).catch(() => undefined); + }, 400); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [open, source, localAdapterId, localThreadId]); + useEffect(() => { if (!companionAvailable || source === "local") { setLoading(false); @@ -1207,7 +1227,9 @@ function AiSidebarSession({ ...(adapter.path ? { path: adapter.path } : {}), prompt: text, ...(options?.readOnly ? { noteAccess: false } : {}), - ...((noteContext || transcript) ? { contextText: [noteContext, transcript].filter(Boolean).join("\n\n") } : {}), + sessionKey: activeLocalThreadId, + ...(noteContext ? { contextText: noteContext } : {}), + ...(transcript ? { historyText: transcript } : {}), ...(snapshot.length ? { attachments: snapshot.map((item) => ({ filename: item.filename, diff --git a/apps/web/src/lib/desktop-acp.ts b/apps/web/src/lib/desktop-acp.ts index 7b2e65d5..29208add 100644 --- a/apps/web/src/lib/desktop-acp.ts +++ b/apps/web/src/lib/desktop-acp.ts @@ -78,6 +78,10 @@ export type DesktopAcpPromptInput = { path?: string; prompt: string; contextText?: string; + // Replayed only when this chat has no live ACP process. A live session already contains it. + historyText?: string; + // Keeps one agent process for this sidebar chat. Omit it for a one-shot call. + sessionKey?: string; attachments?: DesktopAcpAttachment[]; // Infographic edits apply a validated proposal in the editor. They must not // also grant the agent write access to the note document. @@ -201,6 +205,12 @@ export const authenticateDesktopAcpAdapter = async (input: { id: DesktopAcpAdapt return desktop.authenticateAcpAdapter(input); }; +export const prepareDesktopAcp = async (input: Omit) => { + const desktop = bridge(); + if (!desktop?.prepareAcp || !input.sessionKey) return { ready: false as const }; + return desktop.prepareAcp(input); +}; + export const promptDesktopAcp = async (input: DesktopAcpPromptInput): Promise => { const desktop = bridge(); if (!desktop?.promptAcp) throw new Error("desktop_acp_unavailable"); diff --git a/apps/web/src/lib/local-agent-threads.ts b/apps/web/src/lib/local-agent-threads.ts index 76f8b5c0..81ec2631 100644 --- a/apps/web/src/lib/local-agent-threads.ts +++ b/apps/web/src/lib/local-agent-threads.ts @@ -214,7 +214,7 @@ const clipLine = (value: string) => { return text.length > LOCAL_AGENT_TRANSCRIPT_LINE ? `${text.slice(0, LOCAL_AGENT_TRANSCRIPT_LINE)}…` : text; }; -// One-shot ACP calls do not keep the agent process. The current thread's earlier replies are the session. +// Sent only when the desktop has to start a new ACP process. A live chat already has this history. export const localAgentTranscript = ( turns: readonly { threadId: string; message: string; response: string; status: string; createdAt: string }[], threadId: string, diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 80e6c065..e040c575 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -152,11 +152,19 @@ interface EdgeEverDesktopBridge { detail?: string; authMethods?: Array<{ id: string; name: string }>; }>; + prepareAcp?(input: { + adapterId: "codex" | "claudeCode" | "antigravity" | "openClaw" | "hermesAgent" | "grokBuild" | "deepseekHarness" | "piAgent" | "workbuddyCn" | "workbuddyIntl"; + path?: string; + sessionKey?: string; + noteAccess?: boolean; + }): Promise<{ ready: boolean }>; promptAcp?(input: { adapterId: "codex" | "claudeCode" | "antigravity" | "openClaw" | "hermesAgent" | "grokBuild" | "deepseekHarness" | "piAgent" | "workbuddyCn" | "workbuddyIntl"; path?: string; prompt: string; contextText?: string; + historyText?: string; + sessionKey?: string; attachments?: Array<{ filename: string; mediaType: string; dataBase64: string }>; noteAccess?: boolean; }): Promise<{ requestId: string; rejectedAttachments?: Array<{ filename: string; reason: string }> }>;