diff --git a/apps/desktop/docs/acp-traffic-logging.md b/apps/desktop/docs/acp-traffic-logging.md new file mode 100644 index 00000000..23e1edf3 --- /dev/null +++ b/apps/desktop/docs/acp-traffic-logging.md @@ -0,0 +1,128 @@ +# ACP stdio traffic logging + +The desktop app talks to the local Devo server over **ACP** (Agent Client Protocol) on **stdio**: JSON-RPC messages on the child process stdin/stdout. To debug that wire protocol, the main process can append every message to a **JSONL** file. + +This uses the same enable switch and default output location as the CLI **Protocol Trace** (see [`docs/protocol-trace.md`](../../../docs/protocol-trace.md)). + +Implementation lives in: + +- `src/main/acp-traffic-log.ts` — logger, `DEVO_PROTOCOL_TRACE`, and path resolution +- `src/main/acp-stdio-client.ts` — records each stdin/stdout message +- `src/main/devo-manager.ts` — wires the logger into the stdio client at startup + +## Enabling + +Set `DEVO_PROTOCOL_TRACE=1` (or `true`) **before starting the desktop app**. The value is read once when the main process loads `devo-manager`; changing it at runtime has no effect until you restart the app. + +### Development (from repo) + +**macOS / Linux** + +```bash +export DEVO_PROTOCOL_TRACE=1 +cd apps/desktop && bun run dev +``` + +**Windows (PowerShell)** + +```powershell +$env:DEVO_PROTOCOL_TRACE = "1" +cd apps\desktop; bun run dev +``` + +### Packaged app + +Set `DEVO_PROTOCOL_TRACE` in the environment used to launch Devo (shell profile, shortcut, CI job, etc.). + +## Output location + +Trace files are written to `DEVO_HOME/traces/` (default `~/.devo/traces/`) using the naming pattern `protocol--.ndjsonl`, where `` is the Electron main-process PID. + +To write to a specific path instead, set `DEVO_PROTOCOL_TRACE_FILE`: + +```bash +DEVO_PROTOCOL_TRACE=1 DEVO_PROTOCOL_TRACE_FILE=/tmp/my-trace.ndjsonl bun run dev +``` + +If `DEVO_HOME` cannot be resolved, the trace falls back to `/devo-traces/`. + +| Platform | Default traces directory | +|----------|--------------------------| +| macOS / Linux | `~/.devo/traces/` | +| Windows | `%USERPROFILE%\.devo\traces\` | + +## View the log path in the UI + +When logging is enabled, open **Settings → Server**. Under **Developer options**, expand **ACP traffic log** to see the active file path. + +The renderer loads this via `window.devo.acpTraffic.getState()` (`acp-traffic-log:state` IPC). + +## Log format + +Each line is one JSON object (JSONL). Fields: + +| Field | Description | +|-------|-------------| +| `timestamp` | ISO-8601 time when the entry was written | +| `direction` | `desktop-to-server`, `server-to-desktop`, or `system` | +| `kind` | `request`, `response`, `notification`, `invalid`, or `closed` | +| `id` | JSON-RPC id when present | +| `method` | ACP method name when present | +| `payload` | Full JSON-RPC message or system metadata | + +Example line: + +```json +{"timestamp":"2026-06-27T01:02:03.004Z","direction":"desktop-to-server","kind":"request","id":1,"method":"initialize","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}} +``` + +The CLI protocol trace records raw wire lines (`seq`, `dir`, `bytes`, `line`). The desktop trace records parsed ACP messages with direction and kind, which is easier to filter when debugging the managed runtime. + +### What is recorded + +`StdioAcpClient` logs: + +- **desktop → server**: outbound requests and responses (e.g. permission replies) +- **server → desktop**: inbound responses, notifications, and server-initiated requests +- **system**: invalid stdout lines and transport `closed` events (with error text) + +### What is not recorded + +- **Server stderr** is not written to the traffic log. Non-empty stderr lines are emitted to the main-process console as `[main:acp-stdio-client]` warnings (`[stderr] …`). +- **Renderer ↔ main IPC** is not included; only the stdio link to `devo server --transport stdio`. + +## Inspect the log + +**Tail live** + +```bash +tail -f ~/.devo/traces/protocol-*.ndjsonl +``` + +**Pretty-print with jq** + +```bash +jq -c '{ts: .timestamp, dir: .direction, kind, method, id}' ~/.devo/traces/protocol-*.ndjsonl +``` + +**Filter by method** + +```bash +jq 'select(.method == "session/prompt")' ~/.devo/traces/protocol-*.ndjsonl +``` + +## Security + +The log can contain sensitive data: prompts, file paths, tool arguments, model/provider details, and credentials in params. Use a private path, disable logging when not debugging, and do not commit or share log files. The feature is disabled by default. + +## Legacy environment variables + +These are **ignored**: + +- `TRAFFIC_LOG_PATH` +- `DEVO_DESKTOP_ACP_TRAFFIC_LOG` +- `DEVO_DESKTOP_ACP_TRAFFIC_LOG_PATH` + +## Related debugging + +For general main-process diagnostics (spawn errors, slow IPC handlers, transport close), watch the terminal where Electron runs. Log lines are tagged `[main:]`, e.g. `[main:acp-stdio-client]` and `[main:devo-manager]`. diff --git a/apps/desktop/packages/devo-ai-sdk/package.json b/apps/desktop/packages/devo-ai-sdk/package.json index 952f3a17..976f7a11 100644 --- a/apps/desktop/packages/devo-ai-sdk/package.json +++ b/apps/desktop/packages/devo-ai-sdk/package.json @@ -5,7 +5,8 @@ "license": "MIT", "type": "module", "exports": { - "./v2/client": "./src/v2/client.ts" + "./v2/client": "./src/v2/client.ts", + "./v2/acp-client-support": "./src/v2/acp-client-support.ts" }, "dependencies": { "ajv": "^8.20.0" diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts index 68b7bc7a..85c1e056 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts @@ -51,15 +51,20 @@ export function defaultCwd(): string { return process.env.HOME ?? process.cwd() } +let sharedIpcTransport: DevoAcpTransport | null = null + export function createIpcTransport(): DevoAcpTransport { + if (sharedIpcTransport) return sharedIpcTransport + const api = globalThis.window?.devo?.acp if (!api) throw new Error("window.devo.acp is not available") - return { + sharedIpcTransport = { request: (method, params, directory) => api.request({ method, params, directory }), respond: (id, result) => api.respond({ id, result }), subscribe: (listener) => api.subscribe(listener), connected: () => api.connected(), } + return sharedIpcTransport } export function providerDataFromConfigOptions(configOptions: AcpConfigOption[]): { diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index 3dfd6325..15e67f9b 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -536,6 +536,21 @@ function sortedMessages(messages: Message[]): Message[] { return [...messages].sort(compareMessages) } +const initializePromises = new WeakMap>() + +const DESKTOP_INITIALIZE_PARAMS = { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { + name: "devo-desktop", + title: "Devo Desktop", + version: "0.1.0", + }, +} as const + function recentMessages(messages: Message[], limit: number | undefined): Message[] { const sorted = sortedMessages(messages) if (limit === undefined || sorted.length <= limit) return sorted @@ -1103,18 +1118,15 @@ class AcpClient { private async ensureInitialized(): Promise { if (this.initialized) return await this.open() - await this.request("initialize", { - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, - clientInfo: { - name: "devo-desktop", - title: "Devo Desktop", - version: "0.1.0", - }, - }) + if (!this.transport) throw new Error("Devo ACP transport is not connected") + if (this.initialized) return + + let promise = initializePromises.get(this.transport) + if (!promise) { + promise = this.request("initialize", DESKTOP_INITIALIZE_PARAMS).then(() => {}) + initializePromises.set(this.transport, promise) + } + await promise this.initialized = true } @@ -1150,6 +1162,10 @@ class AcpClient { private handleTransportEvent(event: DevoAcpTransportEvent): void { if (event.type === "closed") { + if (this.transport) { + initializePromises.delete(this.transport) + } + this.initialized = false this.events.close() return } diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx index 96a29326..a3d15d6e 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx @@ -300,8 +300,15 @@ function TranscriptMarkdownHeading({ ) } +type TranscriptMarkdownRuleProps = ComponentProps<"hr"> & { node?: unknown } + +function TranscriptMarkdownRule({ node: _node, ..._props }: TranscriptMarkdownRuleProps) { + return null +} + // Product requirement: transcript Markdown headings should look like bold body text, // not oversized section titles or headings with divider rules. +// Horizontal rules (--- / ***) are hidden; section breaks rely on paragraph spacing. const transcriptMarkdownComponents: NonNullable = { h1: TranscriptMarkdownHeading, h2: TranscriptMarkdownHeading, @@ -309,6 +316,7 @@ const transcriptMarkdownComponents: NonNullable { expect(env).toMatchObject({ CUSTOM_FLAG: "1", KEEP: "base", - PATH: "/Users/tester/.devo/bin:/custom/bin", + PATH: `${path.join("/Users/tester", ".devo", "bin")}:/custom/bin`, }) }) @@ -223,6 +224,128 @@ describe("StdioAcpClient", () => { ]) }) + test("reuses the first initialize result for later initialize calls", async () => { + const records: unknown[] = [] + let writeCount = 0 + const client = new StdioAcpClient({ + trafficLogger: { + getState: () => ({ enabled: true, path: null }), + record: (entry) => records.push(entry), + }, + }) + ;(client as unknown as { child: unknown }).child = { + killed: false, + pid: 123, + stdin: { + destroyed: false, + writable: true, + writableEnded: false, + write: (_line: string, callback: (error?: Error) => void) => { + writeCount += 1 + callback() + return true + }, + }, + } + + const first = client.request("initialize", { protocolVersion: 1 }) + await Promise.resolve() + ;(client as unknown as { handleLine: (line: string) => void }).handleLine( + JSON.stringify({ jsonrpc: "2.0", id: 1, result: { ok: true } }), + ) + await expect(first).resolves.toEqual({ ok: true }) + + const second = client.request("initialize", { protocolVersion: 1 }) + await expect(second).resolves.toEqual({ ok: true }) + + expect(writeCount).toBe(1) + expect( + records.filter( + (entry) => + (entry as { kind?: string; method?: string }).kind === "request" && + (entry as { method?: string }).method === "initialize", + ), + ).toHaveLength(1) + }) + + test("scopes outgoing requests with the project directory", async () => { + const records: unknown[] = [] + const client = new StdioAcpClient({ + trafficLogger: { + getState: () => ({ enabled: true, path: null }), + record: (entry) => records.push(entry), + }, + }) + ;(client as unknown as { child: unknown }).child = { + killed: false, + pid: 123, + stdin: { + destroyed: false, + writable: true, + writableEnded: false, + write: (_line: string, callback: (error?: Error) => void) => { + callback() + return true + }, + }, + } + + const response = client.request("session/list", {}, "/repo/project") + await Promise.resolve() + ;(client as unknown as { handleLine: (line: string) => void }).handleLine( + JSON.stringify({ jsonrpc: "2.0", id: 1, result: { sessions: [] } }), + ) + + await expect(response).resolves.toEqual({ sessions: [] }) + expect(records[0]).toMatchObject({ + direction: "desktop-to-server", + kind: "request", + method: "session/list", + payload: { + params: { cwd: "/repo/project" }, + }, + }) + }) + + test("does not add project directory to unscoped requests", async () => { + const records: unknown[] = [] + const client = new StdioAcpClient({ + trafficLogger: { + getState: () => ({ enabled: true, path: null }), + record: (entry) => records.push(entry), + }, + }) + ;(client as unknown as { child: unknown }).child = { + killed: false, + pid: 123, + stdin: { + destroyed: false, + writable: true, + writableEnded: false, + write: (_line: string, callback: (error?: Error) => void) => { + callback() + return true + }, + }, + } + + const response = client.request("session/prompt", { sessionId: "s1", prompt: [] }, "/repo/project") + await Promise.resolve() + ;(client as unknown as { handleLine: (line: string) => void }).handleLine( + JSON.stringify({ jsonrpc: "2.0", id: 1, result: { stopReason: "end_turn" } }), + ) + + await expect(response).resolves.toEqual({ stopReason: "end_turn" }) + expect(records[0]).toMatchObject({ + direction: "desktop-to-server", + kind: "request", + method: "session/prompt", + payload: { + params: { sessionId: "s1", prompt: [] }, + }, + }) + }) + test("records server requests, notifications, invalid lines, and closed events", () => { const records: unknown[] = [] const client = new StdioAcpClient({ diff --git a/apps/desktop/src/main/acp-stdio-client.ts b/apps/desktop/src/main/acp-stdio-client.ts index 718ca751..09217df7 100644 --- a/apps/desktop/src/main/acp-stdio-client.ts +++ b/apps/desktop/src/main/acp-stdio-client.ts @@ -45,7 +45,7 @@ export type AcpTransportEvent = export type AcpTransportListener = (event: AcpTransportEvent) => void export interface AcpTransport { - request(method: string, params?: unknown): Promise + request(method: string, params?: unknown, directory?: string): Promise respond(id: JsonRpcId, result: unknown): Promise subscribe(listener: AcpTransportListener): () => void connected(): boolean @@ -165,6 +165,23 @@ export function routeAcpLine(line: string): AcpIncomingMessage { return { type: "invalid", error: "JSON-RPC message has no id or method", line } } +const DIRECTORY_SCOPED_METHODS = new Set([ + "session/list", + "model/config", + "model/config/set", + "skills/list", + "skills/changed", + "reference/search/start", + "command/exec", +]) + +function scopeRequestParams(method: string, params: unknown, directory?: string): unknown { + if (!directory || !DIRECTORY_SCOPED_METHODS.has(method)) return params + if (!params || typeof params !== "object" || Array.isArray(params)) return params + if ("cwd" in params) return params + return { ...params, cwd: directory } +} + export class StdioAcpClient implements AcpTransport { private child: ChildProcessWithoutNullStreams | null = null private nextId = 1 @@ -172,6 +189,8 @@ export class StdioAcpClient implements AcpTransport { private pendingMethods = new Map() private events = new EventEmitter() private stopped = false + private initializeResult: unknown | undefined + private initializeInFlight: Promise | null = null constructor(private readonly options: StdioAcpClientOptions = {}) {} @@ -230,11 +249,39 @@ export class StdioAcpClient implements AcpTransport { }) } - async request(method: string, params: unknown = {}): Promise { + async request(method: string, params: unknown = {}, directory?: string): Promise { + if (method === "initialize") { + return this.requestInitialize(params, directory) + } + return this.sendRequest(method, params, directory) + } + + private async requestInitialize(params: unknown, directory?: string): Promise { + if (this.initializeResult !== undefined) { + return this.initializeResult + } + if (this.initializeInFlight) { + return this.initializeInFlight + } + this.initializeInFlight = this.sendRequest("initialize", params, directory) + .then((result) => { + this.initializeResult = result + this.initializeInFlight = null + return result + }) + .catch((error) => { + this.initializeInFlight = null + throw error + }) + return this.initializeInFlight + } + + private async sendRequest(method: string, params: unknown, directory?: string): Promise { this.start() const id = this.nextId++ const child = this.requireChild() - const payload = { jsonrpc: "2.0", id, method, params } + const scopedParams = scopeRequestParams(method, params, directory) + const payload = { jsonrpc: "2.0", id, method, params: scopedParams } const response = new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }) }) @@ -390,6 +437,8 @@ export class StdioAcpClient implements AcpTransport { private close(error: Error): void { this.child = null + this.initializeResult = undefined + this.initializeInFlight = null this.recordTraffic({ direction: "system", kind: "closed", diff --git a/apps/desktop/src/main/acp-traffic-log.test.ts b/apps/desktop/src/main/acp-traffic-log.test.ts index 6e597019..a390f0e1 100644 --- a/apps/desktop/src/main/acp-traffic-log.test.ts +++ b/apps/desktop/src/main/acp-traffic-log.test.ts @@ -3,8 +3,14 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { - TRAFFIC_LOG_PATH_ENV, + DEVO_HOME_ENV, + PROTOCOL_TRACE_ENV, + PROTOCOL_TRACE_FILE_ENV, createAcpTrafficLoggerFromEnv, + findDevoHome, + formatProtocolTraceTimestamp, + isProtocolTraceEnabled, + resolveProtocolTracePath, } from "./acp-traffic-log" function withTempDir(run: (dir: string) => T): T { @@ -18,12 +24,31 @@ function withTempDir(run: (dir: string) => T): T { const fixedClock = () => new Date("2026-06-27T01:02:03.004Z") -describe("ACP traffic log env trigger", () => { - test("does not create or write a log when TRAFFIC_LOG_PATH is unset or empty", () => { +describe("protocol trace env trigger", () => { + test("isProtocolTraceEnabled accepts 1 and true", () => { + expect({ + unset: isProtocolTraceEnabled(undefined), + empty: isProtocolTraceEnabled(" "), + one: isProtocolTraceEnabled("1"), + trueValue: isProtocolTraceEnabled("true"), + TrueValue: isProtocolTraceEnabled("TRUE"), + zero: isProtocolTraceEnabled("0"), + }).toEqual({ + unset: false, + empty: false, + one: true, + trueValue: true, + TrueValue: true, + zero: false, + }) + }) + + test("does not create or write a log when DEVO_PROTOCOL_TRACE is unset or empty", () => { withTempDir((dir) => { const logger = createAcpTrafficLoggerFromEnv({ - env: {}, + env: { [DEVO_HOME_ENV]: dir }, clock: fixedClock, + pid: 42, }) logger.record({ @@ -36,17 +61,21 @@ describe("ACP traffic log env trigger", () => { expect({ state: logger.getState(), - logsDirExists: existsSync(path.join(dir, "logs")), + tracesDirExists: existsSync(path.join(dir, "traces")), }).toEqual({ state: { enabled: false, path: null }, - logsDirExists: false, + tracesDirExists: false, }) }) withTempDir((dir) => { const logger = createAcpTrafficLoggerFromEnv({ - env: { [TRAFFIC_LOG_PATH_ENV]: " " }, + env: { + [DEVO_HOME_ENV]: dir, + [PROTOCOL_TRACE_ENV]: " ", + }, clock: fixedClock, + pid: 42, }) logger.record({ @@ -57,22 +86,25 @@ describe("ACP traffic log env trigger", () => { expect({ state: logger.getState(), - logsDirExists: existsSync(path.join(dir, "logs")), + tracesDirExists: existsSync(path.join(dir, "traces")), }).toEqual({ state: { enabled: false, path: null }, - logsDirExists: false, + tracesDirExists: false, }) }) }) - test("ignores the removed DEVO_DESKTOP_ACP_TRAFFIC_LOG trigger", () => { + test("ignores removed desktop-specific triggers", () => { withTempDir((dir) => { const logger = createAcpTrafficLoggerFromEnv({ env: { + [DEVO_HOME_ENV]: dir, DEVO_DESKTOP_ACP_TRAFFIC_LOG: "1", DEVO_DESKTOP_ACP_TRAFFIC_LOG_PATH: path.join(dir, "old", "traffic.jsonl"), + TRAFFIC_LOG_PATH: path.join(dir, "old", "traffic.jsonl"), }, clock: fixedClock, + pid: 42, }) logger.record({ @@ -85,22 +117,59 @@ describe("ACP traffic log env trigger", () => { expect({ state: logger.getState(), - logsDirExists: existsSync(path.join(dir, "old")), + oldDirExists: existsSync(path.join(dir, "old")), }).toEqual({ state: { enabled: false, path: null }, - logsDirExists: false, + oldDirExists: false, + }) + }) + }) + + test("writes JSONL to DEVO_HOME/traces when DEVO_PROTOCOL_TRACE is enabled", () => { + withTempDir((dir) => { + const expectedPath = path.join(dir, "traces", "protocol-42-20260627T010203Z.ndjsonl") + const logger = createAcpTrafficLoggerFromEnv({ + env: { + [DEVO_HOME_ENV]: dir, + [PROTOCOL_TRACE_ENV]: "1", + }, + clock: fixedClock, + pid: 42, + }) + + logger.record({ + direction: "system", + kind: "closed", + payload: { error: "transport closed" }, + }) + + const lines = readFileSync(expectedPath, "utf-8").trim().split("\n") + expect({ + state: logger.getState(), + entry: JSON.parse(lines[0]), + }).toEqual({ + state: { enabled: true, path: expectedPath }, + entry: { + timestamp: "2026-06-27T01:02:03.004Z", + direction: "system", + kind: "closed", + payload: { error: "transport closed" }, + }, }) }) }) - test("writes JSONL when TRAFFIC_LOG_PATH is provided", () => { + test("writes JSONL to DEVO_PROTOCOL_TRACE_FILE when provided", () => { withTempDir((dir) => { - const logPath = path.join(dir, "custom", "traffic.jsonl") + const logPath = path.join(dir, "custom", "trace.ndjsonl") const logger = createAcpTrafficLoggerFromEnv({ env: { - [TRAFFIC_LOG_PATH_ENV]: ` ${logPath} `, + [DEVO_HOME_ENV]: dir, + [PROTOCOL_TRACE_ENV]: "true", + [PROTOCOL_TRACE_FILE_ENV]: ` ${logPath} `, }, clock: fixedClock, + pid: 42, }) logger.record({ @@ -124,4 +193,27 @@ describe("ACP traffic log env trigger", () => { }) }) }) + + test("falls back to temp devo-traces when DEVO_HOME is invalid", () => { + const logPath = resolveProtocolTracePath({ + env: { + [DEVO_HOME_ENV]: path.join(tmpdir(), "missing-devo-home-for-trace"), + [PROTOCOL_TRACE_ENV]: "1", + }, + clock: fixedClock, + pid: 99, + }) + + expect(logPath).toMatch(/devo-traces[\\/]protocol-99-20260627T010203Z\.ndjsonl$/) + }) + + test("findDevoHome honors DEVO_HOME and defaults to ~/.devo", () => { + withTempDir((dir) => { + expect(findDevoHome({ [DEVO_HOME_ENV]: dir })).toBe(path.resolve(dir)) + }) + }) + + test("formatProtocolTraceTimestamp matches server naming", () => { + expect(formatProtocolTraceTimestamp(fixedClock())).toBe("20260627T010203Z") + }) }) diff --git a/apps/desktop/src/main/acp-traffic-log.ts b/apps/desktop/src/main/acp-traffic-log.ts index eff79e5d..8ac3f7ce 100644 --- a/apps/desktop/src/main/acp-traffic-log.ts +++ b/apps/desktop/src/main/acp-traffic-log.ts @@ -1,7 +1,10 @@ -import { appendFileSync, mkdirSync } from "node:fs" +import { appendFileSync, mkdirSync, statSync, writeFileSync } from "node:fs" +import { homedir, tmpdir } from "node:os" import path from "node:path" -export const TRAFFIC_LOG_PATH_ENV = "TRAFFIC_LOG_PATH" +export const PROTOCOL_TRACE_ENV = "DEVO_PROTOCOL_TRACE" +export const PROTOCOL_TRACE_FILE_ENV = "DEVO_PROTOCOL_TRACE_FILE" +export const DEVO_HOME_ENV = "DEVO_HOME" export type AcpTrafficDirection = "desktop-to-server" | "server-to-desktop" | "system" export type AcpTrafficKind = "request" | "response" | "notification" | "invalid" | "closed" @@ -28,14 +31,69 @@ export interface AcpTrafficLogger { interface CreateAcpTrafficLoggerOptions { env?: Record clock?: () => Date + pid?: number } -export function createAcpTrafficLoggerFromEnv({ +export function isProtocolTraceEnabled(value: string | undefined): boolean { + if (!value?.trim()) return false + const normalized = value.trim() + return normalized === "1" || normalized.toLowerCase() === "true" +} + +export function findDevoHome(env: Record = process.env): string { + const explicit = env[DEVO_HOME_ENV]?.trim() + if (explicit) { + const resolved = path.resolve(explicit) + const stat = statSync(resolved, { throwIfNoEntry: false }) + if (!stat?.isDirectory()) { + throw new Error(`DEVO_HOME points to ${explicit}, but that path is not a directory`) + } + return resolved + } + return path.join(homedir(), ".devo") +} + +export function formatProtocolTraceTimestamp(date: Date): string { + return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z") +} + +export function resolveProtocolTracePath({ env = process.env, clock = () => new Date(), -}: CreateAcpTrafficLoggerOptions): AcpTrafficLogger { - const logPath = env[TRAFFIC_LOG_PATH_ENV]?.trim() || null + pid = process.pid, +}: { + env?: Record + clock?: () => Date + pid?: number +} = {}): string | null { + if (!isProtocolTraceEnabled(env[PROTOCOL_TRACE_ENV])) return null + const explicit = env[PROTOCOL_TRACE_FILE_ENV]?.trim() + if (explicit) { + const resolved = path.resolve(explicit) + mkdirSync(path.dirname(resolved), { recursive: true }) + return resolved + } + + const fileName = `protocol-${pid}-${formatProtocolTraceTimestamp(clock())}.ndjsonl` + + try { + const base = path.join(findDevoHome(env), "traces") + mkdirSync(base, { recursive: true }) + return path.join(base, fileName) + } catch { + const base = path.join(tmpdir(), "devo-traces") + mkdirSync(base, { recursive: true }) + return path.join(base, fileName) + } +} + +export function createAcpTrafficLoggerFromEnv({ + env = process.env, + clock = () => new Date(), + pid = process.pid, +}: CreateAcpTrafficLoggerOptions = {}): AcpTrafficLogger { + const logPath = resolveProtocolTracePath({ env, clock, pid }) return new AcpTrafficFileLogger({ enabled: logPath !== null, path: logPath }, clock) } @@ -43,7 +101,12 @@ class AcpTrafficFileLogger implements AcpTrafficLogger { constructor( private readonly state: AcpTrafficLogState, private readonly clock: () => Date, - ) {} + ) { + if (this.state.enabled && this.state.path) { + mkdirSync(path.dirname(this.state.path), { recursive: true }) + writeFileSync(this.state.path, "", "utf-8") + } + } getState(): AcpTrafficLogState { return { ...this.state } @@ -52,7 +115,6 @@ class AcpTrafficFileLogger implements AcpTrafficLogger { record(entry: AcpTrafficLogRecord): void { if (!this.state.enabled || !this.state.path) return - mkdirSync(path.dirname(this.state.path), { recursive: true }) appendFileSync( this.state.path, `${JSON.stringify({ timestamp: this.clock().toISOString(), ...entry })}\n`, diff --git a/apps/desktop/src/main/devo-manager.ts b/apps/desktop/src/main/devo-manager.ts index 6bb8d59f..0559d738 100644 --- a/apps/desktop/src/main/devo-manager.ts +++ b/apps/desktop/src/main/devo-manager.ts @@ -1,7 +1,9 @@ import type { AcpTransport, AcpTransportEvent, AcpTransportListener, JsonRpcId } from "./acp-stdio-client" import { app } from "electron" import { - TRAFFIC_LOG_PATH_ENV, + DEVO_HOME_ENV, + PROTOCOL_TRACE_ENV, + PROTOCOL_TRACE_FILE_ENV, createAcpTrafficLoggerFromEnv, type AcpTrafficLogger, type AcpTrafficLogState, @@ -17,7 +19,9 @@ const log = createLogger("devo-manager") const STDIO_URL = "stdio://local" const acpTrafficLogStartupEnv = { - [TRAFFIC_LOG_PATH_ENV]: process.env[TRAFFIC_LOG_PATH_ENV], + [DEVO_HOME_ENV]: process.env[DEVO_HOME_ENV], + [PROTOCOL_TRACE_ENV]: process.env[PROTOCOL_TRACE_ENV], + [PROTOCOL_TRACE_FILE_ENV]: process.env[PROTOCOL_TRACE_FILE_ENV], } export interface DevoServer { @@ -75,9 +79,13 @@ export async function restartServer(): Promise { return ensureServer() } -export async function requestAcp(method: string, params?: unknown): Promise { +export async function requestAcp( + method: string, + params?: unknown, + directory?: string, +): Promise { const client = await ensureClient() - return client.request(method, params) + return client.request(method, params, directory) } export async function respondAcp(id: JsonRpcId, result: unknown): Promise { @@ -94,15 +102,17 @@ export function isAcpConnected(): boolean { return stdioClient?.connected() ?? false } +const sharedAcpTransport: AcpTransport = { + request: requestAcp, + respond: respondAcp, + subscribe: subscribeAcp, + connected: isAcpConnected, + pid: () => stdioClient?.pid() ?? null, + stop: stopServer, +} + export function getAcpTransport(): AcpTransport { - return { - request: requestAcp, - respond: respondAcp, - subscribe: subscribeAcp, - connected: isAcpConnected, - pid: () => stdioClient?.pid() ?? null, - stop: stopServer, - } + return sharedAcpTransport } export function getAcpTrafficLogState(): AcpTrafficLogState { diff --git a/apps/desktop/src/main/ipc-handlers.ts b/apps/desktop/src/main/ipc-handlers.ts index d4087fa0..e2d6afaf 100644 --- a/apps/desktop/src/main/ipc-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers.ts @@ -205,10 +205,8 @@ export function registerIpcHandlers(): void { "acp:request", withLogging( "acp:request", - async (_, request: { method: string; params?: unknown; directory?: string }) => { - void request.directory - return await requestAcp(request.method, request.params) - }, + async (_, request: { method: string; params?: unknown; directory?: string }) => + await requestAcp(request.method, request.params, request.directory), ), ) diff --git a/apps/desktop/src/main/settings-store.test.ts b/apps/desktop/src/main/settings-store.test.ts index 8e034cf5..c189e97d 100644 --- a/apps/desktop/src/main/settings-store.test.ts +++ b/apps/desktop/src/main/settings-store.test.ts @@ -31,6 +31,7 @@ function expectedSettings() { colorScheme: "dark", themeId: "default", displayMode: "default", + hideThinkingWhileWorking: true, rendererPreferencesMigrated: false, }, openIn: { @@ -106,6 +107,7 @@ describe("settings-store", () => { colorScheme: "system", themeId: "default", displayMode: "default", + hideThinkingWhileWorking: true, rendererPreferencesMigrated: false, }, openIn: { @@ -162,6 +164,7 @@ describe("settings-store", () => { colorScheme: "system", themeId: "cortex", displayMode: "verbose", + hideThinkingWhileWorking: true, rendererPreferencesMigrated: false, }, }) diff --git a/apps/desktop/src/preload/api.d.ts b/apps/desktop/src/preload/api.d.ts index 9e939f4c..3c7d9ea5 100644 --- a/apps/desktop/src/preload/api.d.ts +++ b/apps/desktop/src/preload/api.d.ts @@ -179,6 +179,8 @@ export interface AppearanceSettings { colorScheme: ColorScheme themeId: string displayMode: DisplayMode + /** Hide model reasoning/thinking blocks while the agent is still working. */ + hideThinkingWhileWorking: boolean rendererPreferencesMigrated: boolean } diff --git a/apps/desktop/src/renderer/atoms/preferences.ts b/apps/desktop/src/renderer/atoms/preferences.ts index 73dd8f65..e3cee2be 100644 --- a/apps/desktop/src/renderer/atoms/preferences.ts +++ b/apps/desktop/src/renderer/atoms/preferences.ts @@ -65,6 +65,11 @@ export const displayModeAtom = atomWithStorage( DEFAULT_APPEARANCE_SETTINGS.displayMode, ) +export const hideThinkingWhileWorkingAtom = atomWithStorage( + "devo:hideThinkingWhileWorking", + DEFAULT_APPEARANCE_SETTINGS.hideThinkingWhileWorking, +) + export const themeAtom = atomWithStorage("devo:theme", DEFAULT_APPEARANCE_SETTINGS.themeId) export const colorSchemeAtom = atomWithStorage( diff --git a/apps/desktop/src/renderer/atoms/ui.ts b/apps/desktop/src/renderer/atoms/ui.ts index ae65df6b..b811c64c 100644 --- a/apps/desktop/src/renderer/atoms/ui.ts +++ b/apps/desktop/src/renderer/atoms/ui.ts @@ -11,6 +11,26 @@ export const commandPaletteOpenAtom = atom(false) */ export const viewedSessionIdAtom = atom(null) +/** + * Last non-settings route visited before opening Settings. + * Used to restore navigation when leaving Settings. + */ +export const lastAppRouteAtom = atom(null) + +/** Session kept mounted in the background while Settings is open. */ +export interface SettingsBackgroundSession { + sessionId: string + projectSlug: string +} + +export const settingsBackgroundSessionAtom = atom(null) + +/** Whether the Settings overlay is covering the main content (set by SidebarLayout). */ +export const settingsOverlayOpenAtom = atom(false) + +/** Last known scrollTop for a session's chat view (used when returning from Settings). */ +export const sessionScrollTopFamily = atomFamily((_sessionId: string) => atom(null)) + // ============================================================ // Review Panel State // ============================================================ diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts b/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts index 670bf257..68680ad8 100644 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts +++ b/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts @@ -7,30 +7,19 @@ const chatToolCallSource = readFileSync(new URL("./chat-tool-call.tsx", import.m const rendererCssSource = readFileSync(new URL("../../index.css", import.meta.url), "utf8") describe("shouldDefaultOpen", () => { - test("collapses tool output by default", () => { - const tools = ["bash", "read", "edit", "write", "apply_patch", "glob", "grep", "list"] - - expect(Object.fromEntries(tools.map((tool) => [tool, shouldDefaultOpen(tool, "completed")]))).toEqual({ - bash: false, - read: false, - edit: false, - write: false, - apply_patch: false, - glob: false, - grep: false, - list: false, - }) - }) - - test("keeps error output expanded", () => { + test("keeps all tool output collapsed by default", () => { expect({ - bash: shouldDefaultOpen("bash", "error"), - read: shouldDefaultOpen("read", "error"), + bash: shouldDefaultOpen("bash", "completed"), + read: shouldDefaultOpen("read", "completed"), + bashError: shouldDefaultOpen("bash", "error"), + readError: shouldDefaultOpen("read", "error"), unknown: shouldDefaultOpen("unknown", "error"), }).toEqual({ - bash: true, - read: true, - unknown: true, + bash: false, + read: false, + bashError: false, + readError: false, + unknown: false, }) }) }) diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx index ceca91a5..bd5d0670 100644 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx @@ -40,13 +40,17 @@ import { } from "lucide-react" import type { ReactNode } from "react" import { memo, useCallback, useMemo } from "react" -import { useToolElapsedTime } from "../../hooks/use-elapsed-time" import type { BundledLanguage } from "shiki" import { viewFileInDiffPanelAtom } from "../../atoms/ui" import { detectContentLanguage, detectLanguage, prettyPrintJson } from "../../lib/language" import type { FilePart, ToolPart, ToolStateCompleted } from "../../lib/types" import { SubAgentCard } from "./sub-agent-card" -import { getToolCategory, ToolCard } from "./tool-card" +import type { ToolCategory } from "./tool-card" +import { + TranscriptDisclosure, + TranscriptDisclosureContent, + TranscriptDisclosureTrigger, +} from "./transcript-disclosure" import { formatToolPathForDisplay, getFirstApplyPatchPath, @@ -445,10 +449,7 @@ function BashContent({ part }: { part: ToolPart }) { Output @@ -761,7 +762,7 @@ function TodoContent({ part }: { part: ToolPart }) { /** Error content for any tool */ function ErrorContent({ error }: { error: string }) { return ( -
+