Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions apps/desktop/docs/acp-traffic-logging.md
Original file line number Diff line number Diff line change
@@ -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-<pid>-<utc_timestamp>.ndjsonl`, where `<pid>` 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 `<temp_dir>/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:<module>]`, e.g. `[main:acp-stdio-client]` and `[main:devo-manager]`.
3 changes: 2 additions & 1 deletion apps/desktop/packages/devo-ai-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[]): {
Expand Down
40 changes: 28 additions & 12 deletions apps/desktop/packages/devo-ai-sdk/src/v2/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,21 @@ function sortedMessages(messages: Message[]): Message[] {
return [...messages].sort(compareMessages)
}

const initializePromises = new WeakMap<DevoAcpTransport, Promise<void>>()

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
Expand Down Expand Up @@ -1103,18 +1118,15 @@ class AcpClient {
private async ensureInitialized(): Promise<void> {
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
}

Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,23 @@ 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<MessageResponseProps["components"]> = {
h1: TranscriptMarkdownHeading,
h2: TranscriptMarkdownHeading,
h3: TranscriptMarkdownHeading,
h4: TranscriptMarkdownHeading,
h5: TranscriptMarkdownHeading,
h6: TranscriptMarkdownHeading,
hr: TranscriptMarkdownRule,
}

export const MessageResponse = memo(
Expand Down
125 changes: 124 additions & 1 deletion apps/desktop/src/main/acp-stdio-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from "node:path"
import { describe, expect, test } from "bun:test"
import { StdioAcpClient, buildServerProcessEnv, routeAcpLine } from "./acp-stdio-client"

Expand Down Expand Up @@ -53,7 +54,7 @@ describe("StdioAcpClient", () => {
expect(env).toMatchObject({
CUSTOM_FLAG: "1",
KEEP: "base",
PATH: "/Users/tester/.devo/bin:/custom/bin",
PATH: `${path.join("/Users/tester", ".devo", "bin")}:/custom/bin`,
})
})

Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading