diff --git a/src/notify.ts b/src/notify.ts index e0086cd..8480861 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -1,4 +1,49 @@ -import { writeFileSync } from "fs" +import { writeFileSync, openSync, writeSync, closeSync } from "fs" + +/** + * Write data directly to the controlling terminal, bypassing any + * stdio redirection that the plugin host (OpenCode) may have set up. + * + * Tries platform-appropriate TTY devices in order: + * Unix → /dev/tty + * Win32 → CONOUT$, CON + * + * Falls back to process.stderr (less commonly redirected than stdout). + * Returns true on first successful write. + */ +function writeTty(data: string): boolean { + const devices = + process.platform === "win32" + ? ["/dev/tty", "CONOUT$", "CON"] + : ["/dev/tty"] + + for (const device of devices) { + try { + const fd = openSync(device, "w") + try { + writeSync(fd, data) + return true + } finally { + closeSync(fd) + } + } catch { + // Device not available on this platform — try next + } + } + + // Last resort: stderr is often still connected to the terminal + // even when stdout is piped for plugin RPC. + if (process.stderr?.isTTY) { + try { + process.stderr.write(data) + return true + } catch { + // ignore + } + } + + return false +} /** * Send a Warp notification via OSC 777 escape sequence. @@ -8,13 +53,20 @@ import { writeFileSync } from "fs" function warpNotify(title: string, body: string): void { if (!process.env.WARP_CLI_AGENT_PROTOCOL_VERSION) return - try { - // OSC 777 format: \033]777;notify;;<body>\007 - const sequence = `\x1b]777;notify;${title};${body}\x07` - writeFileSync("/dev/tty", sequence) - } catch { - // Silently ignore if /dev/tty is not available - } + // Guard against known-broken Warp builds that set the protocol + // env var but cannot actually render structured notifications. + // Mirrors the check in claude-code-warp's should-use-structured.sh. + if (!process.env.WARP_CLIENT_VERSION) return + + const LAST_BROKEN_STABLE = "v0.2026.03.25.08.24.stable_05" + const LAST_BROKEN_PREVIEW = "v0.2026.03.25.08.24.preview_05" + const ver = process.env.WARP_CLIENT_VERSION + if (ver.includes("stable") && ver <= LAST_BROKEN_STABLE) return + if (ver.includes("preview") && ver <= LAST_BROKEN_PREVIEW) return + + // OSC 777 format: \033]777;notify;<title>;<body>\007 + const sequence = `\x1b]777;notify;${title};${body}\x07` + writeTty(sequence) } -export { warpNotify } +export { warpNotify, writeTty } diff --git a/tests/index.test.ts b/tests/index.test.ts index e3628b5..6153145 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,7 +1,8 @@ -import { describe, it } from "node:test" +import { describe, it, mock, beforeEach, afterEach } from "node:test" import assert from "node:assert/strict" import { truncate, extractTextFromParts } from "../src/utils" import { buildPayload } from "../src/payload" +import { writeTty } from "../src/notify" describe("truncate", () => { it("returns string unchanged when under maxLen", () => { @@ -63,6 +64,15 @@ describe("extractTextFromParts", () => { }) }) +describe("writeTty", () => { + it("returns a boolean (does not throw on current platform)", () => { + // writeTty should gracefully return true/false without throwing, + // regardless of platform and whether a tty is actually available. + const result = writeTty("test-data") + assert.strictEqual(typeof result, "boolean") + }) +}) + describe("question_asked event", () => { it("builds a valid question_asked payload", () => { const payload = JSON.parse(