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;
;\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;;\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(