Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

- The background service no longer aborts a running turn when a phone opens another session or starts a new one. It keeps up to 3 sessions open, each with its own runtime: a session in a turn keeps running in the background, and opening it again shows it as it is. With all 3 running, the phone is told to stop one or wait. `remote_hello.host.parallelSessions` says the service does this, and `list_sessions` marks sessions as `open` and `running`
- Remote control spends a pairing secret only when the phone completes the handshake (`remote_ready`), not when its first handshake message arrives. A phone whose connection timed out mid-handshake (the apps give each address 3 seconds; iOS's first connect can wait on the Local Network prompt) had used up its QR code, so its retry was refused and pairing needed a new code. The secret still pairs one phone: of two phones racing with it, the first to finish pairs and the other is refused
- On the Bun runtime (Pi-Bolt), a `console.log` from an extension or a dependency in RPC, JSON, ACP or MCP mode (and `pi-bolt service`) no longer goes into the JSON stream on stdout. Bun's console methods (`log`, `info`, `debug`, `dir`, `dirxml`, `table`, `trace`, `count`, `group`, `write`) print to fd 1 without calling the replaced `process.stdout.write` ([oven-sh/bun#47982](https://github.com/oven-sh/bun/issues/47982)); while stdout is taken over they now print to stderr, formatted as on Node

## [1.5.0] - 2026-11-02

Expand Down
70 changes: 70 additions & 0 deletions packages/coding-agent/src/core/output-guard.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import { Console } from "node:console";
import { format } from "node:util";

interface StdoutTakeoverState {
rawStdoutWrite: (chunk: string, callback?: (error?: Error | null) => void) => boolean;
rawStderrWrite: (chunk: string, callback?: (error?: Error | null) => void) => boolean;
originalStdoutWrite: typeof process.stdout.write;
/** Console methods replaced while stdout is taken over, to put back at the end. */
originalConsoleMethods: Map<string, unknown>;
}

/**
* The console methods that print to stdout on Bun without calling `process.stdout.write`
* (https://github.com/oven-sh/bun/issues/47982). Node's console calls the replaced `process.stdout.write`, so its
* output goes to stderr; on Bun it went straight to fd 1, into the JSON stream. `trace` and Bun's `write` print to fd 1
* too and are replaced separately. Bun's time* methods already print to stderr; count/countReset and the group methods
* are replaced together so their state stays in one console.
*/
const BUN_STDOUT_CONSOLE_METHODS = [
"log",
"info",
"debug",
"dir",
"dirxml",
"table",
"count",
"countReset",
"group",
"groupCollapsed",
"groupEnd",
] as const;

let stdoutTakeoverState: StdoutTakeoverState | undefined;

const RAW_STDOUT_RETRY_DELAY_MS = 10;
Expand Down Expand Up @@ -80,15 +106,59 @@ export function takeOverStdout(): void {
rawStdoutWrite,
rawStderrWrite,
originalStdoutWrite,
originalConsoleMethods: process.versions.bun ? routeBunConsoleThroughStdoutWrite() : new Map(),
};
}

/**
* Point Bun's stdout console methods at a `Console` over `process.stdout`, which calls the replaced write as Node's
* console does, so the output and its formatting match Node. Bun's `console.write` (raw, no newline) goes the same way.
*/
function routeBunConsoleThroughStdoutWrite(): Map<string, unknown> {
const target = console as unknown as Record<string, unknown>;
const originals = new Map<string, unknown>();
const nodeConsole = new Console({ stdout: process.stdout, stderr: process.stderr });
for (const name of BUN_STDOUT_CONSOLE_METHODS) {
originals.set(name, target[name]);
target[name] = nodeConsole[name].bind(nodeConsole);
}
// Bun's Console prints a trace's stack without its "Trace: <message>" line; Node prints it to stderr with the line.
const trace = (...data: unknown[]): void => {
const error = new Error(format(...data));
error.name = "Trace";
Error.captureStackTrace(error, trace);
nodeConsole.error(error.stack);
};
originals.set("trace", target.trace);
target.trace = trace;
if (typeof target.write === "function") {
originals.set("write", target.write);
target.write = (...data: Array<string | ArrayBufferView>): number => {
let bytes = 0;
for (const chunk of data) {
const buffer =
typeof chunk === "string"
? Buffer.from(chunk)
: Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
process.stdout.write(buffer.toString());
bytes += buffer.byteLength;
}
return bytes;
};
}
return originals;
}

export function restoreStdout(): void {
if (!stdoutTakeoverState) {
return;
}

process.stdout.write = stdoutTakeoverState.originalStdoutWrite;
const target = console as unknown as Record<string, unknown>;
for (const [name, method] of stdoutTakeoverState.originalConsoleMethods) {
target[name] = method;
}
stdoutTakeoverState = undefined;
}

Expand Down
40 changes: 40 additions & 0 deletions packages/coding-agent/test/fixtures/rpc-console-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Run by test/suite/regressions/rpc-console-stdout.test.ts under Bun: an RPC session (faux provider) whose extension
// prints with every console method that writes to stdout. Commands come on stdin; the session ends when stdin closes.
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import type { AgentSessionRuntime } from "../../src/core/agent-session-runtime.ts";
import type { ExtensionAPI } from "../../src/core/extensions/types.ts";
import { takeOverStdout } from "../../src/core/output-guard.ts";
import { runRpcMode } from "../../src/modes/rpc/rpc-mode.ts";
import { createHarness } from "../suite/harness.ts";

// As main.ts does: stdout is taken over before extensions load.
takeOverStdout();
const harness = await createHarness({
extensionFactories: [
(pi: ExtensionAPI) => {
console.log("console-marker extension loaded", { a: 1 });
pi.on("agent_start", () => {
console.log("console-marker log %s", "x");
console.info("console-marker info");
console.debug("console-marker debug");
console.dir({ marker: "console-marker dir" });
console.dirxml("console-marker dirxml");
console.table([{ marker: "console-marker table" }]);
console.trace("console-marker trace");
console.count("console-marker count");
console.group("console-marker group");
console.log("console-marker in group");
console.groupEnd();
(console as { write?: (text: string) => void }).write?.("console-marker write\n");
});
},
],
});
harness.setResponses([fauxAssistantMessage("done")]);
process.on("exit", () => harness.cleanup());

await runRpcMode({
session: harness.session,
dispose: async () => {},
setRebindSession: () => {},
} as unknown as AgentSessionRuntime);
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { spawn, spawnSync } from "node:child_process";
import { Console } from "node:console";
import { fileURLToPath } from "node:url";
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentSessionRuntime } from "../../../src/core/agent-session-runtime.ts";
import { restoreStdout } from "../../../src/core/output-guard.ts";
import { runRpcMode } from "../../../src/modes/rpc/rpc-mode.ts";
import { createHarness, type Harness } from "../harness.ts";

// Bun's console methods print to fd 1 without calling a replaced process.stdout.write
// (https://github.com/oven-sh/bun/issues/47982), so an extension's console.log went into the RPC stream.

const rpcInput = vi.hoisted(() => ({ lineHandler: undefined as ((line: string) => void) | undefined }));

vi.mock("../../../src/modes/rpc/jsonl.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../src/modes/rpc/jsonl.ts")>()),
Comment thread
Kushalkhemka marked this conversation as resolved.
Resolved
attachJsonlLineReader: vi.fn((_stream: NodeJS.ReadableStream, onLine: (line: string) => void) => {
rpcInput.lineHandler = onLine;
return () => {
rpcInput.lineHandler = undefined;
};
}),
}));

const fixture = fileURLToPath(new URL("../../fixtures/rpc-console-session.ts", import.meta.url));
const hasBun = spawnSync("bun", ["--version"]).status === 0;

function expectJsonLines(stdout: string): Array<Record<string, unknown>> {
const lines = stdout.split("\n").filter((line) => line.length > 0);
expect(lines.length).toBeGreaterThan(0);
return lines.map((line) => JSON.parse(line) as Record<string, unknown>);
}

describe("console output in RPC mode", () => {
const harnesses: Harness[] = [];
afterEach(() => {
for (const harness of harnesses.splice(0)) harness.cleanup();
});

test("an extension's console.log goes to stderr and stdout stays JSON lines (Node)", async () => {
const stdout: string[] = [];
const stderr: string[] = [];
const originalStdoutWrite = process.stdout.write;
const originalStderrWrite = process.stderr.write;
const originalLog = console.log;
const originalInfo = console.info;
// Vitest replaces the global console with its own; use one over process.stdout as Node's global console is.
const nodeConsole = new Console({ stdout: process.stdout, stderr: process.stderr });
console.log = nodeConsole.log.bind(nodeConsole);
console.info = nodeConsole.info.bind(nodeConsole);
process.stdout.write = ((chunk: string, ...rest: unknown[]) => {
stdout.push(String(chunk));
(rest.find((arg) => typeof arg === "function") as (() => void) | undefined)?.();
return true;
}) as typeof process.stdout.write;
process.stderr.write = ((chunk: string, ...rest: unknown[]) => {
stderr.push(String(chunk));
(rest.find((arg) => typeof arg === "function") as (() => void) | undefined)?.();
return true;
}) as typeof process.stderr.write;

try {
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("agent_start", () => {
console.log("console-marker log", { a: 1 });
console.info("console-marker info");
});
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("done")]);
void runRpcMode({
session: harness.session,
dispose: async () => {},
setRebindSession: () => {},
} as unknown as AgentSessionRuntime);
await vi.waitFor(() => expect(rpcInput.lineHandler).toBeDefined());
rpcInput.lineHandler?.(JSON.stringify({ id: "p", type: "prompt", message: "hi" }));
await vi.waitFor(() => expect(stdout.join("")).toContain('"type":"agent_end"'));
} finally {
restoreStdout();
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
console.log = originalLog;
console.info = originalInfo;
}

expect(stdout.join("")).not.toContain("console-marker");
expectJsonLines(stdout.join(""));
expect(stderr.join("")).toContain("console-marker log { a: 1 }\n");
expect(stderr.join("")).toContain("console-marker info\n");
});

test.skipIf(!hasBun)("every stdout console method of an extension goes to stderr under Bun", async () => {
const child = spawn("bun", [fixture], { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
if (stdout.includes('"type":"agent_end"')) child.stdin.end();
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
const exitCode = new Promise<number | null>((resolve) => child.on("exit", resolve));
child.stdin.write(`${JSON.stringify({ id: "p", type: "prompt", message: "hi" })}\n`);
expect(await exitCode).toBe(0);

const events = expectJsonLines(stdout);
expect(events.some((event) => event.type === "agent_end")).toBe(true);
expect(stdout).not.toContain("console-marker");
for (const marker of [
"console-marker extension loaded { a: 1 }\n",
"console-marker log x\n",
"console-marker info\n",
"console-marker debug\n",
"console-marker dir",
"console-marker dirxml\n",
"console-marker table",
"Trace: console-marker trace\n",
"console-marker count: 1\n",
"console-marker group\n console-marker in group\n",
"console-marker write\n",
]) {
expect(stderr).toContain(marker);
}
});
});
Loading