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
18 changes: 10 additions & 8 deletions apps/desktop/electron/main/agent-sidecar.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { HostProcess, ProcessExitHandler, StderrHandler } from "./host-process";
import { redactValue } from "./logger";
import { DEFAULT_RPC_TIMEOUT_MS, rpcTimeoutMs } from "@pi-desktop/shared";
import { DEFAULT_RPC_TIMEOUT_MS, readNdjsonLines, rpcTimeoutMs } from "@pi-desktop/shared";

// stderr lines kept per sidecar so an unexpected exit can be reported with the
// process's last words instead of a bare "agent sidecar exited".
Expand Down Expand Up @@ -123,7 +122,7 @@ export class AgentSidecar {
private host: HostProcess | null = null;
private unsubscribeHost: (() => void) | null = null;
private unsubscribeHostExit: (() => void) | null = null;
private readline?: ReturnType<typeof createInterface>;
private stdoutReader?: ReturnType<typeof readNdjsonLines>;
// Tools served by Electron main itself (e.g. BrowserPreview drives the
// work panel's WebContentsView) — host-core never sees these.
private localTools = new Map<string, LocalToolHandler>();
Expand Down Expand Up @@ -179,9 +178,9 @@ export class AgentSidecar {
this.notifyExit({ code: null, signal: null, intentional: this.disposed });
});

const rl = createInterface({ input: this.child.stdout });
this.readline = rl;
rl.on("line", (line) => void this.onLine(line));
this.stdoutReader = readNdjsonLines(this.child.stdout, (line) =>
void this.onLine(line),
);
}

private recordStderr(text: string) {
Expand Down Expand Up @@ -210,8 +209,8 @@ export class AgentSidecar {
for (const timer of this.localToolTimers) clearTimeout(timer);
this.localToolTimers.clear();
this.handlers.clear();
this.readline?.close();
this.readline = undefined;
this.stdoutReader?.close();
this.stdoutReader = undefined;
this.child.removeAllListeners("exit");
this.child.removeAllListeners("error");
this.child.stderr.removeAllListeners("data");
Expand Down Expand Up @@ -426,6 +425,9 @@ export class AgentSidecar {
try {
msg = JSON.parse(line);
} catch {
console.warn(
`[RPC] Invalid agent-sidecar NDJSON frame (${Buffer.byteLength(line, "utf8")} bytes)`,
);
return;
}

Expand Down
17 changes: 10 additions & 7 deletions apps/desktop/electron/main/host-process.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
ErrorCodes,
MAX_HOST_STDIN_LINE_BYTES,
PROTOCOL_VERSION,
readNdjsonLines,
rpcTimeoutMs,
stripProxyEnv,
} from "@pi-desktop/shared";
Expand Down Expand Up @@ -101,7 +101,7 @@ export class HostProcess {
private exitPromise: Promise<void>;
private resolveExit!: () => void;
private disposePromise?: Promise<void>;
private readline?: ReturnType<typeof createInterface>;
private stdoutReader?: ReturnType<typeof readNdjsonLines>;
private lastStderr = "";
readonly binaryPath: string;
readonly generation = randomUUID();
Expand Down Expand Up @@ -169,9 +169,9 @@ export class HostProcess {
if (this.exitObserved) this.cleanupProcessListeners();
});

const rl = createInterface({ input: this.child.stdout });
this.readline = rl;
rl.on("line", (line) => this.onLine(line));
this.stdoutReader = readNdjsonLines(this.child.stdout, (line) =>
this.onLine(line),
);
}

private closeTransport(error: Error) {
Expand All @@ -184,8 +184,8 @@ export class HostProcess {
}
this.pending.clear();
this.handlers.clear();
this.readline?.close();
this.readline = undefined;
this.stdoutReader?.close();
this.stdoutReader = undefined;
}

private cleanupProcessListeners() {
Expand Down Expand Up @@ -264,6 +264,9 @@ export class HostProcess {
try {
msg = JSON.parse(line);
} catch {
console.warn(
`[RPC] Invalid host-process NDJSON frame (${Buffer.byteLength(line, "utf8")} bytes)`,
);
return;
}
if (msg.id !== undefined && msg.id !== null) {
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/test/rpc-lifecycle-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,30 @@ const rpcTimeoutSource = await readFile(
new URL("../../../packages/shared/src/rpc-timeouts.ts", import.meta.url),
"utf8",
);
const agentSidecarEntrySource = await readFile(
new URL("../../../packages/agent-runtime/src/sidecar.ts", import.meta.url),
"utf8",
);
const apiSource = await readFile(
new URL("../src/lib/api.ts", import.meta.url),
"utf8",
);

test("stdio RPC readers split frames on LF only", () => {
for (const [name, source] of [
["host-process", hostSource],
["agent-sidecar", sidecarSource],
["runtime sidecar", agentSidecarEntrySource],
]) {
assert.match(source, /readNdjsonLines/, `${name} must use LF NDJSON framing`);
assert.doesNotMatch(
source,
/from ["']node:readline["']/,
`${name} must not use readline on the RPC pipe`,
);
}
});

test("sidecar detaches host listeners and gates every child write", () => {
assert.match(sidecarSource, /private closeTransport\(error: Error\)/);
assert.match(sidecarSource, /unsubscribeHostExit/);
Expand Down
9 changes: 8 additions & 1 deletion docs/spec/03-runtime/06-host-rpc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ MVP transport decision (**D001**):

- Process: Electron main spawns Rust host-core sidecar
- Channel: child process stdin/stdout
- Framing: one JSON object per line (NDJSON)
- Framing: one JSON object per LF-delimited line (NDJSON); CRLF is accepted.
U+2028 and U+2029 inside JSON strings are payload, never frame delimiters.
All Node stdio readers preserve UTF-8 characters across input chunks and
release buffered fragments/listeners on transport close. A final unterminated
frame is accepted at EOF for compatibility.
- Invalid JSON frames produce a diagnostic containing only the byte length,
never payload text, before being discarded. Later complete frames remain
readable. Existing session text is not rewritten or migrated.
- Encoding: UTF-8
- Request/response: JSON-RPC 2.0 style

Expand Down
20 changes: 20 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -7503,6 +7503,7 @@ identify the platform validation still needed.

| Acceptance | Scenarios |
|---|---|
| A / C — Unicode stdio framing | E2E-RPC-unicode-separators |
| C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back |
| C / D / Quality — Sidebar row states | E2E-LAYOUT-sidebar-row-states |
| A / C / Quality — Sidebar material and settings return | E2E-LAYOUT-sidebar-settings |
Expand Down Expand Up @@ -13058,3 +13059,22 @@ plugin-form fixtures in an isolated temporary directory at runtime.
Renderer fixtures alone do not prove settings persistence.
- **Specs:** 04-ux/06-settings-ia, 04-ux/08-component-spec,
04-ux/09-interaction-patterns; ADR turn-process-and-thinking-display.

### E2E-RPC-unicode-separators

- **Preconditions:** Built host-core, shared package and agent runtime; isolated
temporary data directory; loopback-only fixture provider. No live credentials.
- **Steps:** Append a user message containing U+2028/U+2029, CJK text, emoji and
escaped CR/LF; read it, restart the host, and read it again. Send a Unicode
prompt through AgentSidecar; restore history through the parent host proxy;
stream and persist a Unicode answer. Send an unknown method containing the
same characters, then a health request.
- **Expected:** Text survives unchanged across persistence and all stdio
directions. Requests settle without RPC timeouts; error replies and subsequent
requests remain usable. No migration of existing sessions is needed.
- **Automation:** `pnpm test:e2e:rpc-unicode`; `packages/shared/src/ndjson.test.ts`
additionally checks every UTF-8 split boundary, consecutive frames, CRLF, EOF and disposal.
- **Specs:** 03-runtime/06-host-rpc-protocol §2.
- **Acceptance:** A (runtime), C (sessions).
- **Milestone:** M6+.
- **Status:** Automated; run against the task/PR integration candidate.
6 changes: 5 additions & 1 deletion docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ MVP 传输决策 (**D001**):

- 流程:Electron 主要生成 Rust host-core sidecar
- 通道:子进程 stdin/stdout
- 成帧:每行一个 JSON 对象 (NDJSON)
- 成帧:每行一个以 LF 分隔的 JSON 对象(NDJSON);接受 CRLF。
JSON 字符串内的 U+2028 与 U+2029 属于载荷,不是帧分隔符。
所有 Node stdio 读取器会跨输入块保留 UTF-8 字符,并在传输关闭时释放缓冲片段和监听器。
为兼容起见,EOF 时接受最后一帧未以换行结束的情况。
- 非法 JSON 帧会先产出仅含字节长度、不含载荷文本的诊断,然后丢弃。后续完整帧仍可读。现有会话文本不会被改写或迁移。
- 编码:UTF-8
- Request/response:JSON-RPC 2.0 风格

Expand Down
12 changes: 12 additions & 0 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -5008,6 +5008,7 @@ IPC 请求无法关闭。

| 验收 | 应用场景 |
|---|---|
| A / C — Unicode stdio 成帧 | E2E-RPC-unicode-separators |
| C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back |
| C / D / Quality — 侧边栏行状态 | E2E-LAYOUT-sidebar-row-states |
| A / C / Quality — 侧栏材质与设置返回 | E2E-LAYOUT-sidebar-settings |
Expand Down Expand Up @@ -7798,3 +7799,14 @@ runner 会在运行时的隔离临时目录中生成六个插件形态 fixture
- **验收**:G(远程市场来源)
- **里程碑**:M6+
- **状态**:草稿

### E2E-RPC-unicode-separators

- **前置条件**:已构建 host-core、shared 与 agent-runtime;隔离的临时数据目录;仅回环的 fixture provider。不使用真实凭证。
- **步骤**:追加一条含 U+2028/U+2029、中文、emoji 以及转义 CR/LF 的用户消息;读取、重启 host 后再读。通过 AgentSidecar 发送 Unicode 提示;经 parent host proxy 恢复历史;流式返回并持久化 Unicode 回复。发送一条含相同字符的未知方法,再发健康检查。
- **预期**:文本在持久化与所有 stdio 方向上保持不变。请求在 RPC 超时前完成;错误回复之后的请求仍可用。无需迁移现有会话。
- **自动化**:`pnpm test:e2e:rpc-unicode`;`packages/shared/src/ndjson.test.ts` 额外覆盖每个 UTF-8 切分位置、连续帧、CRLF、EOF 与销毁。
- **规格**:03-runtime/06-host-rpc-protocol §2。
- **验收**:A(运行时),C(会话)。
- **里程碑**:M6+。
- **状态**:已自动化;针对 task/PR 集成候选运行。
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"test": "pnpm build:js && pnpm -r --if-present test && cargo test -p host-core",
"test:host": "cargo test -p host-core",
"test:e2e": "node scripts/e2e-smoke.mjs",
"test:e2e:rpc-unicode": "node scripts/e2e-rpc-unicode.mjs",
"test:e2e:plan": "node scripts/e2e-plan.mjs",
"test:e2e:plan-ui": "node scripts/e2e-plan-ui.mjs",
"test:e2e:theme-surfaces": "node scripts/e2e-theme-surfaces.mjs",
Expand Down
8 changes: 5 additions & 3 deletions packages/agent-runtime/src/sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
* Protocol: NDJSON JSON-RPC on stdio with Electron main.
* Host access is proxied through main (single host-core process).
*/
import { createInterface } from "node:readline";
import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { copyFile, mkdir, readFile, realpath, stat } from "node:fs/promises";
Expand Down Expand Up @@ -35,6 +34,7 @@ import {
normalizeMode,
normalizeNetworkProxy,
OAUTH_AUTH_KIND,
readNdjsonLines,
} from "@pi-desktop/shared";
import type {
AgentEventEnvelope,
Expand Down Expand Up @@ -658,13 +658,15 @@ async function handle(method: string, params: any): Promise<unknown> {
}
}

const rl = createInterface({ input: process.stdin });
rl.on("line", async (line) => {
readNdjsonLines(process.stdin, async (line) => {
if (!line.trim()) return;
let msg: any;
try {
msg = JSON.parse(line);
} catch {
process.stderr.write(
`[agent-sidecar] Invalid NDJSON frame (${Buffer.byteLength(line, "utf8")} bytes)\n`,
);
return;
}
// Responses to host.proxy requests from parent
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export * from "./command-shells.js";
export * from "./context-compaction.js";
export * from "./rpc-timeouts.js";
export * from "./rpc-limits.js";
export * from "./ndjson.js";
export * from "./subagent-definition.js";
export * from "./subagent-presets.js";
export * from "./provider-presets.js";
Expand Down
Loading