Revert "Merge pull request #2076: fix(coding-agent): prefer daemonizing CLI clipboards over arboard on Linux"

This reverts commit b3ed88d398, reversing
changes made to 26c6326f52.
This commit is contained in:
can1357
2026-06-10 08:31:32 +02:00
parent 9001ab3732
commit 5e7640c237
3 changed files with 41 additions and 276 deletions
-1
View File
@@ -361,7 +361,6 @@
- Fixed the bash result renderer recomputing styled output (`split` / `replaceTabs` / `truncateToVisualLines`) on every TUI repaint, which scaled with both transcript length and per-row output size. With a long captured session every keystroke walked hundreds of bash rows; the reporter on issue #2081 observed Ctrl+X/Ctrl+C feeling unresponsive because the main thread was pinned re-styling scrollback. The result renderer now caches its produced lines keyed by `(width, previewLines, expanded, rawOutput, isPartial)`, mirroring the existing eval-renderer cache; `invalidate()` clears the cache as before. Hot-path repaints with unchanged inputs are now O(1) ([#2081](https://github.com/can1357/oh-my-pi/issues/2081)).
- Fixed startup model fallback selection so sessions now prefer each provider’s configured default model before choosing the first available authenticated model
- Fixed implicit model selection path for tools and sessions by honoring persisted model-provider order when no explicit pattern is provided
- Fixed `/copy` (and every other UI clipboard action) leaving the X11 / Wayland clipboard empty on Linux when running through tmux + QTerminal or any other stack that drops OSC 52. The native `arboard` backend cannot retain X11 / Wayland selection ownership across the short-lived napi call, so `copyToClipboard` was returning success while the desktop clipboard stayed unchanged. `utils/clipboard.ts` now reorders the backend chain on Linux to try `wl-copy` / `xclip` / `xsel` (which fork after reading stdin and keep serving the selection) before the native backend, honors an `OMP_CLIPBOARD_COMMAND` shell-command escape hatch for unusual setups, and logs a single warning when every backend fails so the silent-success regression cannot recur ([#2075](https://github.com/can1357/oh-my-pi/issues/2075))
- Fixed the working spinner appearing to ignore Esc for 2-3 seconds when an interrupt lands mid-tool. Esc fires the abort synchronously, but the agent loop only stops the loader at `agent_end`, which it cannot reach until every in-flight tool settles in `executeToolCalls`' `await Promise.allSettled(...)` — and process/subagent/kernel-owning tools tear down gracefully (SIGTERM, 2-3s grace, SIGKILL), so the loader kept showing the unchanged "Working…/<intent>" line and read as a dropped keypress. The loader now switches to "Interrupting…" the instant Esc requests the abort and freezes intent-driven label updates until the turn unwinds (`EventController.notifyInterrupting`), so the interrupt is acknowledged immediately even while teardown completes.
- Fixed a flaky JS eval worker startup that intermittently failed unrelated CI runs. The worker-ready wait reused Bun's 5s default per-test timeout as its floor, so a slow cold-start under `--isolate` + high concurrency was aborted mid-init; terminating a still-initializing Bun worker is the documented SIGILL/SIGTRAP crash trigger, which took down the whole test file. Worker init now floors at a fixed 15s infrastructure budget (independent of, and still dominated by, a larger per-cell `timeout`), and the JS eval test suites set a 20s file-local timeout so cold starts complete instead of being torn down.
- Fixed reviewer-style subagent yields crashing the calling eval cell when a caller-supplied output schema declares `additionalProperties: false` without a `findings` property. `normalizeCompleteData` now consults the active validator before splicing collected `report_finding` entries onto the yielded payload, so injection is suppressed when the schema would reject it — keeping the executor's post-mortem validation in lockstep with the in-tool `yield` validation that already accepted the same raw payload ([#2070](https://github.com/can1357/oh-my-pi/issues/2070))
+39 -141
View File
@@ -3,12 +3,6 @@ import type { ClipboardImage } from "@oh-my-pi/pi-natives";
import * as native from "@oh-my-pi/pi-natives";
import { logger } from "@oh-my-pi/pi-utils";
/** Env var users can set to override clipboard copy (e.g. `xclip -selection clipboard -in -silent`). */
const CUSTOM_COPY_COMMAND_ENV = "OMP_CLIPBOARD_COMMAND";
/** Timeout for any external clipboard helper. xclip / wl-copy fork after reading stdin in well under this budget. */
const COPY_TIMEOUT_MS = 5_000;
function hasDisplay(): boolean {
return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
}
@@ -17,154 +11,58 @@ function isWsl(): boolean {
return process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
}
/**
* Linux clipboard CLI fallbacks, listed in attempt order.
*
* The native `arboard` backend cannot retain X11 / Wayland selection ownership after the calling
* process exits, and for short-lived napi calls it can drop ownership before any consumer sees the
* selection — leaving the clipboard empty even though `set_text` returned success (see #2075 on
* QTerminal + tmux). `wl-copy` / `xclip` / `xsel` all fork after reading stdin and serve the
* selection until another app claims it, so the payload survives.
*
* `requiresEnv` skips backends whose display socket is absent.
*/
interface LinuxCliBackend {
readonly cmd: readonly string[];
readonly requiresEnv: "WAYLAND_DISPLAY" | "DISPLAY";
}
const LINUX_CLI_BACKENDS: readonly LinuxCliBackend[] = [
{ cmd: ["wl-copy"], requiresEnv: "WAYLAND_DISPLAY" },
{ cmd: ["xclip", "-selection", "clipboard", "-in"], requiresEnv: "DISPLAY" },
{ cmd: ["xsel", "--clipboard", "--input"], requiresEnv: "DISPLAY" },
];
/**
* Spawn a clipboard CLI with `text` on stdin. Returns `true` only on a clean exit. A missing binary,
* non-zero exit, or any spawn error is treated as a fall-through signal so the caller can try the
* next backend.
*/
async function spawnClipboardCli(cmd: readonly string[], text: string): Promise<boolean> {
try {
const proc = Bun.spawn({
cmd: cmd as string[],
stdin: new TextEncoder().encode(text),
stdout: "ignore",
stderr: "ignore",
});
const timer = setTimeout(() => proc.kill(), COPY_TIMEOUT_MS);
try {
const exitCode = await proc.exited;
return exitCode === 0;
} finally {
clearTimeout(timer);
}
} catch {
return false;
}
}
async function tryCustomCommand(text: string): Promise<boolean> {
const command = process.env[CUSTOM_COPY_COMMAND_ENV];
if (!command) return false;
const shell = process.platform === "win32" ? ["cmd.exe", "/c", command] : ["/bin/sh", "-c", command];
try {
const proc = Bun.spawn({
cmd: shell,
stdin: new TextEncoder().encode(text),
stdout: "ignore",
stderr: "ignore",
});
const timer = setTimeout(() => proc.kill(), COPY_TIMEOUT_MS);
try {
const exitCode = await proc.exited;
if (exitCode === 0) return true;
logger.warn(`clipboard: ${CUSTOM_COPY_COMMAND_ENV} exited ${exitCode}`, { command });
return false;
} finally {
clearTimeout(timer);
}
} catch (err) {
logger.warn(`clipboard: ${CUSTOM_COPY_COMMAND_ENV} failed`, { command, error: String(err) });
return false;
}
}
async function tryLinuxCliCopy(text: string): Promise<boolean> {
for (const backend of LINUX_CLI_BACKENDS) {
if (!process.env[backend.requiresEnv]) continue;
if (await spawnClipboardCli(backend.cmd, text)) return true;
}
return false;
}
function emitOsc52(text: string): void {
if (!process.stdout.isTTY) return;
const onError = (err: unknown) => {
process.stdout.off("error", onError);
// Prevent unhandled 'error' from crashing the process when stdout is a closed pipe.
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") return;
};
try {
const encoded = Buffer.from(text).toString("base64");
const osc52 = `\x1b]52;c;${encoded}\x07`;
process.stdout.on("error", onError);
process.stdout.write(osc52, err => {
process.stdout.off("error", onError);
// OSC 52 is best-effort; swallow EPIPE on broken pipes.
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") return;
});
} catch (err) {
process.stdout.off("error", onError);
if ((err as NodeJS.ErrnoException | null | undefined)?.code !== "EPIPE") {
// All write failures are ignored — OSC 52 is best-effort.
}
}
}
/**
* Copy text to the system clipboard.
*
* Order of attempts:
*
* 1. **OSC 52** — emitted on a real TTY so remote terminals (SSH/mosh) that support the sequence
* can capture the clipboard. Harmless on terminals that don't.
* 2. **`OMP_CLIPBOARD_COMMAND`** — user-supplied shell command receiving the text on stdin.
* Escape hatch for unusual setups (e.g. `xclip -selection clipboard -in -silent`).
* 3. **Termux**: `termux-clipboard-set`.
* 4. **Linux**: `wl-copy` / `xclip` / `xsel`. These commands daemonize so the clipboard payload
* survives our process exit; the native `arboard` backend cannot retain X11/Wayland selection
* ownership across exit and leaves the clipboard empty in QTerminal + tmux and similar
* short-lived CLI scenarios (#2075).
* 5. **Native `arboard`** — required on macOS/Windows and the final fallback when no Linux CLI tool
* is installed. When this last step fails too, a single warning is logged so the silent-success
* UX from #2075 cannot recur unnoticed.
* Emits OSC 52 first when running in a real terminal (works over SSH/mosh),
* then attempts native clipboard copy as best-effort for local sessions.
* On Termux, tries `termux-clipboard-set` before native.
*
* @param text - UTF-8 text to place on the clipboard.
*/
export async function copyToClipboard(text: string): Promise<void> {
emitOsc52(text);
if (await tryCustomCommand(text)) return;
if (process.env.TERMUX_VERSION) {
if (process.stdout.isTTY) {
const onError = (err: unknown) => {
process.stdout.off("error", onError);
// Prevent unhandled 'error' from crashing the process when stdout is a closed pipe.
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") {
return;
}
};
try {
execSync("termux-clipboard-set", { input: text, timeout: COPY_TIMEOUT_MS });
return;
} catch {
// Fall through to native.
const encoded = Buffer.from(text).toString("base64");
const osc52 = `\x1b]52;c;${encoded}\x07`;
process.stdout.on("error", onError);
process.stdout.write(osc52, err => {
process.stdout.off("error", onError);
// If stdout is closed (e.g. piped to a process that exits early),
// ignore EPIPE and proceed with native clipboard best-effort.
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") {
return;
}
});
} catch (err) {
process.stdout.off("error", onError);
if ((err as NodeJS.ErrnoException | null | undefined)?.code !== "EPIPE") {
// Ignore all write failures (OSC 52 is best-effort).
}
}
}
if (process.platform === "linux" && (await tryLinuxCliCopy(text))) return;
// Also try native tools (best effort for local sessions)
try {
if (process.env.TERMUX_VERSION) {
try {
execSync("termux-clipboard-set", { input: text, timeout: 5000 });
return;
} catch {
// Fall through to native
}
}
await native.copyToClipboard(text);
} catch (err) {
logger.warn(
"clipboard: native copy failed and no CLI fallback succeeded. On Linux install xclip or wl-clipboard, or set OMP_CLIPBOARD_COMMAND.",
{ error: String(err) },
);
} catch {
// Ignore — clipboard copy is best-effort
}
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { copyToClipboard, readImageFromClipboard } from "@oh-my-pi/pi-coding-agent/utils/clipboard";
import { readImageFromClipboard } from "@oh-my-pi/pi-coding-agent/utils/clipboard";
import * as native from "@oh-my-pi/pi-natives";
import type { Subprocess } from "bun";
@@ -50,14 +50,7 @@ function restorePlatform(): void {
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
}
const ENV_KEYS = [
"WSL_DISTRO_NAME",
"WSL_INTEROP",
"DISPLAY",
"WAYLAND_DISPLAY",
"TERMUX_VERSION",
"OMP_CLIPBOARD_COMMAND",
] as const;
const ENV_KEYS = ["WSL_DISTRO_NAME", "WSL_INTEROP", "DISPLAY", "WAYLAND_DISPLAY", "TERMUX_VERSION"] as const;
let savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
beforeEach(() => {
@@ -177,128 +170,3 @@ describe("readImageFromClipboard dispatch", () => {
expect(nativeSpy).not.toHaveBeenCalled();
});
});
type ExitMap = Record<string, number>;
/**
* Mock `Bun.spawn` to record every invocation and resolve each child with the exit code mapped
* from the first argv element. Unmapped commands default to `1` (failure) so a test that forgets
* to whitelist a backend fails loudly instead of silently passing through.
*/
function spyCopySpawns(calls: SpawnCall[], exits: ExitMap) {
function mockSpawn(opts: SpawnOptions & { cmd: string[] }): Subprocess;
function mockSpawn(cmd: string[], opts?: SpawnOptions): Subprocess;
function mockSpawn(first: string[] | (SpawnOptions & { cmd: string[] }), second?: SpawnOptions): Subprocess {
const cmd = Array.isArray(first) ? first : first.cmd;
const options = Array.isArray(first) ? (second ?? ({} as SpawnOptions)) : (first as SpawnOptions);
calls.push({ cmd, options });
const exit = exits[cmd[0] ?? ""] ?? 1;
return fakeProcess("", exit);
}
return vi.spyOn(Bun, "spawn").mockImplementation(mockSpawn);
}
describe("copyToClipboard dispatch", () => {
it("uses xclip before the native backend on Linux+X11", async () => {
setPlatform("linux");
process.env.DISPLAY = ":0";
const calls: SpawnCall[] = [];
spyCopySpawns(calls, { xclip: 0 });
const nativeSpy = vi.spyOn(native, "copyToClipboard");
await copyToClipboard("hello");
expect(calls).toHaveLength(1);
expect(calls[0]?.cmd).toEqual(["xclip", "-selection", "clipboard", "-in"]);
expect(nativeSpy).not.toHaveBeenCalled();
});
it("prefers wl-copy when a Wayland display is present", async () => {
setPlatform("linux");
process.env.WAYLAND_DISPLAY = "wayland-0";
process.env.DISPLAY = ":0";
const calls: SpawnCall[] = [];
spyCopySpawns(calls, { "wl-copy": 0, xclip: 0 });
const nativeSpy = vi.spyOn(native, "copyToClipboard");
await copyToClipboard("hi");
expect(calls).toHaveLength(1);
expect(calls[0]?.cmd).toEqual(["wl-copy"]);
expect(nativeSpy).not.toHaveBeenCalled();
});
it("falls through to xsel when xclip exits non-zero", async () => {
setPlatform("linux");
process.env.DISPLAY = ":0";
const calls: SpawnCall[] = [];
spyCopySpawns(calls, { xclip: 1, xsel: 0 });
const nativeSpy = vi.spyOn(native, "copyToClipboard");
await copyToClipboard("hi");
expect(calls.map(c => c.cmd[0])).toEqual(["xclip", "xsel"]);
expect(nativeSpy).not.toHaveBeenCalled();
});
it("falls back to the native backend when every Linux CLI fails", async () => {
setPlatform("linux");
process.env.DISPLAY = ":0";
const calls: SpawnCall[] = [];
spyCopySpawns(calls, {});
const nativeSpy = vi.spyOn(native, "copyToClipboard").mockReturnValue();
await copyToClipboard("hi");
expect(calls.map(c => c.cmd[0])).toEqual(["xclip", "xsel"]);
expect(nativeSpy).toHaveBeenCalledTimes(1);
expect(nativeSpy).toHaveBeenCalledWith("hi");
});
it("delegates straight to the native backend on macOS without spawning CLI helpers", async () => {
setPlatform("darwin");
const spawnSpy = vi.spyOn(Bun, "spawn");
const nativeSpy = vi.spyOn(native, "copyToClipboard").mockReturnValue();
await copyToClipboard("hello");
expect(spawnSpy).not.toHaveBeenCalled();
expect(nativeSpy).toHaveBeenCalledTimes(1);
});
it("honors OMP_CLIPBOARD_COMMAND before any other backend", async () => {
setPlatform("linux");
process.env.DISPLAY = ":0";
process.env.OMP_CLIPBOARD_COMMAND = "xclip -selection clipboard -in -silent";
const calls: SpawnCall[] = [];
spyCopySpawns(calls, { "/bin/sh": 0, xclip: 0 });
const nativeSpy = vi.spyOn(native, "copyToClipboard");
await copyToClipboard("hi");
expect(calls).toHaveLength(1);
expect(calls[0]?.cmd).toEqual(["/bin/sh", "-c", "xclip -selection clipboard -in -silent"]);
expect(nativeSpy).not.toHaveBeenCalled();
});
it("falls through past a failing OMP_CLIPBOARD_COMMAND so the request still reaches a backend", async () => {
setPlatform("linux");
process.env.DISPLAY = ":0";
process.env.OMP_CLIPBOARD_COMMAND = "false";
const calls: SpawnCall[] = [];
spyCopySpawns(calls, { "/bin/sh": 2, xclip: 0 });
const nativeSpy = vi.spyOn(native, "copyToClipboard");
await copyToClipboard("hi");
expect(calls.map(c => c.cmd[0])).toEqual(["/bin/sh", "xclip"]);
expect(nativeSpy).not.toHaveBeenCalled();
});
});