From a4e5a7a98c63321679b24f922238e0737029686f Mon Sep 17 00:00:00 2001 From: Gabriel Parrot Date: Wed, 19 Aug 2026 21:05:32 -0400 Subject: [PATCH 1/2] Fix idle notify and Wayland click-to-focus. Treat a user message as the start of a turn so idle still fires when session.status busy is missing. On Linux, line-buffer gdbus monitor, use the GNOME activation token, and drop desktop-entry so the click reaches us instead of failing to raise Flatpak Zed. --- CHANGELOG.md | 2 ++ README.md | 4 +-- src/activate.ts | 53 ++++++++++++++++++++++++++------- src/engine.ts | 1 + src/notify.ts | 69 ++++++++++++++++++++++++++++--------------- test/activate.test.ts | 14 ++++++++- test/engine.test.ts | 10 +++++++ test/notify.test.ts | 36 ++++++++++++++++++++-- 8 files changed, 150 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 406b345..a668fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Stay silent on idle after ESC / `MessageAbortedError`, a real error, or an idle with no prior busy turn - Do not retract or re-send an idle popup when title or background work sets the session busy - Start the next idle turn on a new user message, not on generic busy +- Treat a new user message as a busy turn so idle still fires if `session.status` busy is missing +- Focus Zed on click under GNOME/Wayland: use the activation token, line-buffer `gdbus monitor`, and do not let `desktop-entry` swallow the click ## 0.2.0 diff --git a/README.md b/README.md index 09e8925..9002c6f 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,8 @@ Do not copy only `src/index.ts` into `~/.config/opencode/plugins/` — the plugi 2. `permission.replied` cancels that timer, records the ID (so a late ask stays silent), and retracts a popup already on screen. 3. If the timer fires, the request is still waiting on you, so a notification is sent. 4. `MessageAbortedError` is ignored. It is not an `opencode error` popup. -5. After a session was busy, `session.status` idle / `session.idle` sends `opencode idle`. ESC, a real error, or an idle with no prior turn stays silent. Title or background work does not retract that popup or send a second one. A new user message starts the next turn. -6. Clicking a popup focuses Zed (`zed://`). It does not open `zed://agent`, which would start a new thread. +5. After a user message or `session.status` busy, `session.status` idle / `session.idle` sends `opencode idle`. ESC, a real error, or an idle with no prior turn stays silent. Title or background work does not retract that popup or send a second one. A new user message starts the next turn. +6. Clicking a popup focuses Zed (`zed://`), using the GNOME/Wayland activation token when the compositor sends one. It does not open `zed://agent`, which would start a new thread. That covers `opencode --auto`, the TUI auto-approve toggle, and any other path that replies before you need to look. diff --git a/src/activate.ts b/src/activate.ts index fa45482..c48ea1e 100644 --- a/src/activate.ts +++ b/src/activate.ts @@ -6,6 +6,7 @@ import type { SpawnSyncFn } from "./notify" export type ActivateTarget = { sessionId?: string clickCommand?: string[] + activationToken?: string } const CHANNELS = ["stable", "preview", "nightly", "dev"] as const @@ -45,36 +46,66 @@ export function activate( env: NodeJS.ProcessEnv = process.env, ) { try { + const opts = spawnOpts(env, target.activationToken) if (target.clickCommand?.length) { const [cmd, ...args] = expandClickCommand(target.clickCommand, target.sessionId) - if (cmd) spawn(cmd, args, { stdio: "ignore", timeout: 5000 }) + if (cmd) spawn(cmd, args, opts) return } const url = zedFocusUrl() - for (const sock of zedSocketCandidates(home, env)) { - if (exists(sock) && sendUnixDgram(sock, url, spawn)) return - } const attempts: Array<[string, string[]]> = [ ["zed", ["-e", url]], ["xdg-open", [url]], + ["gtk-launch", ["dev.zed.Zed"]], ["open", [url]], ["zed", []], ["flatpak", ["run", "dev.zed.Zed"]], ] - for (const [cmd, args] of attempts) { - try { - const result = spawn(cmd, args, { stdio: "ignore", timeout: 5000 }) - if (!result.error && (result.status === 0 || result.status == null)) return - } catch { + if (target.activationToken) { + for (const [cmd, args] of attempts) { + if (run(spawn, cmd, args, opts)) return } } + for (const sock of zedSocketCandidates(home, env)) { + if (exists(sock) && sendUnixDgram(sock, url, spawn, opts)) return + } + for (const [cmd, args] of attempts) { + if (run(spawn, cmd, args, opts)) return + } + } catch { + } +} + +function spawnOpts(env: NodeJS.ProcessEnv, token?: string) { + return { + stdio: "ignore" as const, + timeout: 5000, + ...(token ? { env: { ...env, XDG_ACTIVATION_TOKEN: token } } : {}), + } +} + +function run( + spawn: SpawnSyncFn, + command: string, + args: string[], + opts: { stdio: "ignore"; timeout: number; env?: NodeJS.ProcessEnv }, +) { + try { + const result = spawn(command, args, opts) + return !result.error && (result.status === 0 || result.status == null) } catch { + return false } } -function sendUnixDgram(path: string, payload: string, spawn: SpawnSyncFn) { +function sendUnixDgram( + path: string, + payload: string, + spawn: SpawnSyncFn, + opts: { stdio: "ignore"; timeout: number; env?: NodeJS.ProcessEnv }, +) { try { - const result = spawn("python3", ["-c", SOCKET_SCRIPT, path, payload], { stdio: "ignore", timeout: 2000 }) + const result = spawn("python3", ["-c", SOCKET_SCRIPT, path, payload], { ...opts, timeout: 2000 }) return !result.error && result.status === 0 } catch { return false diff --git a/src/engine.ts b/src/engine.ts index 41e0deb..0f5b186 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -67,6 +67,7 @@ export function createEngine(input: EngineInput): Engine { function beginTurn(sessionId: string) { suppressIdle.delete(sessionId) idleNotified.delete(sessionId) + active.set(sessionId, true) } function markBusy(sessionId?: string) { diff --git a/src/notify.ts b/src/notify.ts index 1d76318..91c5696 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -4,7 +4,7 @@ import { activate, type ActivateTarget } from "./activate" export type SpawnSyncFn = ( command: string, args: string[], - options?: { encoding?: BufferEncoding; stdio?: "ignore"; timeout?: number }, + options?: { encoding?: BufferEncoding; stdio?: "ignore"; timeout?: number; env?: NodeJS.ProcessEnv }, ) => { status?: number | null; error?: Error; stdout?: string | Buffer } export type SpawnFn = ( @@ -35,7 +35,8 @@ export type NotifierInput = { platform?: string } -const ACTION_RE = /ActionInvoked \(uint32 (\d+),\s*'([^']*)'\)/g +const ACTION_RE = /ActionInvoked \(uint32 (\d+),\s*['"]([^'"]*)['"]\)/g +const TOKEN_RE = /ActivationToken \(uint32 (\d+),\s*['"]([^'"]*)['"]\)/g const WIN_APP_ID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe" const WIN_GROUP = "opencode-smart-notify" @@ -69,15 +70,16 @@ function createLinuxNotifier(options: NotifierInput): Notifier { const spawn = options.spawn ?? spawnSync const watch = options.watch ?? nodeSpawn const clicks = new Map() + const tokens = new Map() let watching = false - function runActivate(sessionId?: string) { + function runActivate(sessionId?: string, activationToken?: string) { try { if (options.activate) { - options.activate({ sessionId, clickCommand: options.clickCommand }) + options.activate({ sessionId, clickCommand: options.clickCommand, activationToken }) return } - activate({ sessionId, clickCommand: options.clickCommand }, spawn) + activate({ sessionId, clickCommand: options.clickCommand, activationToken }, spawn) } catch { } } @@ -88,33 +90,56 @@ function createLinuxNotifier(options: NotifierInput): Notifier { extra?.onId?.(id) } + function fire(id: number) { + if (!clicks.has(id)) return + const sessionId = clicks.get(id) + clicks.delete(id) + const activationToken = tokens.get(id) + tokens.delete(id) + runActivate(sessionId, activationToken) + } + + function consume(text: string) { + TOKEN_RE.lastIndex = 0 + ACTION_RE.lastIndex = 0 + const ids = new Set() + let match: RegExpExecArray | null + while ((match = TOKEN_RE.exec(text))) { + const id = Number.parseInt(match[1] ?? "", 10) + if (!Number.isFinite(id) || !clicks.has(id)) continue + tokens.set(id, match[2] ?? "") + ids.add(id) + } + while ((match = ACTION_RE.exec(text))) { + const id = Number.parseInt(match[1] ?? "", 10) + if (!Number.isFinite(id) || !clicks.has(id)) continue + ids.add(id) + } + for (const id of ids) fire(id) + } + function ensureWatch() { if (watching) return watching = true try { - const child = watch("gdbus", ["monitor", "--session", "--dest", "org.freedesktop.Notifications"], { - encoding: "utf8", - }) - child.stdout?.on("data", (chunk) => { - const text = String(chunk) - ACTION_RE.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = ACTION_RE.exec(text))) { - const id = Number.parseInt(match[1] ?? "", 10) - if (!Number.isFinite(id) || !clicks.has(id)) continue - const sessionId = clicks.get(id) - clicks.delete(id) - runActivate(sessionId) - } - }) + const child = watch( + "stdbuf", + ["-oL", "gdbus", "monitor", "--session", "--dest", "org.freedesktop.Notifications"], + { encoding: "utf8" }, + ) + child.stdout?.on("data", (chunk) => consume(String(chunk))) child.on?.("error", () => {}) + child.on?.("exit", () => { + watching = false + }) } catch { + watching = false } } return { send(title: string, body: string, urgency = "normal", extra?: SendExtra) { - const hints = `{'urgency': , 'desktop-entry': <'dev.zed.Zed'>}` + const hints = `{'urgency': }` try { ensureWatch() const printed = spawn( @@ -153,8 +178,6 @@ function createLinuxNotifier(options: NotifierInput): Notifier { "opencode", "-i", "dialog-information-symbolic", - "-h", - "string:desktop-entry:dev.zed.Zed", title, body, ] diff --git a/test/activate.test.ts b/test/activate.test.ts index 4e86496..757f820 100644 --- a/test/activate.test.ts +++ b/test/activate.test.ts @@ -54,6 +54,18 @@ describe("activate", () => { expect(calls[0]?.args.at(-1)).toBe("zed://") }) + test("uses an XDG activation token before the socket so Wayland can focus", () => { + const { spawn, calls } = fakeSpawn() + activate( + { sessionId: "ses_1", activationToken: "gnome-shell/1/token" }, + spawn, + (path) => path === "/tmp/xdg/zed/zed-stable.sock", + "/home/gab", + { XDG_DATA_HOME: "/tmp/xdg", PATH: "/usr/bin" }, + ) + expect(calls[0]).toEqual({ command: "zed", args: ["-e", "zed://"] }) + }) + test("falls back to the zed CLI when no socket exists", () => { const { spawn, calls } = fakeSpawn() activate({ sessionId: "ses_1" }, spawn, () => false, "/home/gab", {}) @@ -63,7 +75,7 @@ describe("activate", () => { test("falls back to launching zed with no args", () => { const { spawn, calls } = fakeSpawn((command, args) => { if (args.includes("zed://")) return { status: 1 } - if (command === "python3") return { status: 1 } + if (command === "python3" || command === "gtk-launch") return { status: 1 } return { status: 0 } }) activate({ sessionId: "ses_1" }, spawn, () => false, "/home/gab", {}) diff --git a/test/engine.test.ts b/test/engine.test.ts index e002801..e5f6c8d 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -240,6 +240,16 @@ describe("createEngine", () => { expect(sent).toEqual([]) }) + test("treats a new user message as the start of a busy turn", () => { + const { engine, sent } = setup() + engine.handle({ + type: "message.updated", + properties: { sessionID: "ses_1", info: { id: "msg_1", role: "user" } }, + }) + engine.handle({ type: "session.idle", properties: { sessionID: "ses_1" } }) + expect(sent).toEqual([{ title: "opencode idle", body: "demo: finished", urgency: "critical", sessionId: "ses_1" }]) + }) + test("skips idle after MessageAbortedError", () => { const { engine, sent } = setup() engine.handle({ type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" } } }) diff --git a/test/notify.test.ts b/test/notify.test.ts index d2f9eef..14e1b2d 100644 --- a/test/notify.test.ts +++ b/test/notify.test.ts @@ -53,7 +53,6 @@ describe("createNotifier", () => { const notifier = createNotifier({ spawn, watch, platform: "linux" }) expect(notifier.send("opencode error", "boom")).toBe(7) expect(calls.some((call) => call.command === "notify-send" && call.args[0] === "-p")).toBe(true) - expect(calls.some((call) => call.args.includes("string:desktop-entry:dev.zed.Zed"))).toBe(true) }) test("swallows notify-send exceptions", () => { @@ -66,7 +65,7 @@ describe("createNotifier", () => { test("activates the matching session when the notification is clicked", () => { const { spawn } = fakeSpawn(() => ({ status: 0, stdout: "(uint32 9,)\n" })) const { watch, emit } = fakeWatch() - const activated: Array<{ sessionId?: string }> = [] + const activated: Array<{ sessionId?: string; activationToken?: string }> = [] const notifier = createNotifier({ spawn, watch, @@ -80,6 +79,39 @@ describe("createNotifier", () => { expect(activated).toEqual([{ sessionId: "ses_1" }]) }) + test("passes a GNOME activation token so Wayland can focus Zed", () => { + const { spawn } = fakeSpawn(() => ({ status: 0, stdout: "(uint32 9,)\n" })) + const { watch, emit } = fakeWatch() + const activated: Array<{ sessionId?: string; activationToken?: string }> = [] + const notifier = createNotifier({ + spawn, + watch, + platform: "linux", + activate(target) { + activated.push(target) + }, + }) + notifier.send("opencode idle", "demo: finished", "critical", { sessionId: "ses_1" }) + emit( + "/org/freedesktop/Notifications: org.freedesktop.Notifications.ActivationToken (uint32 9, 'gnome-shell/1/token')\n", + ) + expect(activated).toEqual([{ sessionId: "ses_1", activationToken: "gnome-shell/1/token" }]) + }) + + test("line-buffers gdbus monitor so a click is not stuck in stdout", () => { + const { spawn } = fakeSpawn(() => ({ status: 0, stdout: "(uint32 1,)\n" })) + const calls: Array<{ command: string; args: string[] }> = [] + const watch: SpawnFn = (command, args) => { + calls.push({ command, args }) + return fakeWatch().watch(command, args) + } + createNotifier({ spawn, watch, platform: "linux" }).send("t", "b") + expect(calls[0]).toEqual({ + command: "stdbuf", + args: ["-oL", "gdbus", "monitor", "--session", "--dest", "org.freedesktop.Notifications"], + }) + }) + test("closes with gdbus when it succeeds", () => { const { spawn, calls } = fakeSpawn(() => ({ status: 0 })) createNotifier({ spawn, watch: fakeWatch().watch, platform: "linux" }).close(7) From 40d025818cfc899016ccbb8087a78fdd4e55d206 Mon Sep 17 00:00:00 2001 From: Gabriel Parrot Date: Wed, 19 Aug 2026 21:14:55 -0400 Subject: [PATCH 2/2] Fall back to gdbus monitor when stdbuf is missing. Assert the XDG activation token is passed through spawn env, and drop stale tokens when a notification is closed. --- src/notify.ts | 33 +++++++++++++++++++++++---------- test/activate.test.ts | 10 ++++++---- test/notify.test.ts | 41 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/src/notify.ts b/src/notify.ts index 91c5696..36bd308 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -118,23 +118,35 @@ function createLinuxNotifier(options: NotifierInput): Notifier { for (const id of ids) fire(id) } - function ensureWatch() { - if (watching) return - watching = true + function takeWatch(command: string, args: string[], onError?: () => void) { try { - const child = watch( - "stdbuf", - ["-oL", "gdbus", "monitor", "--session", "--dest", "org.freedesktop.Notifications"], - { encoding: "utf8" }, - ) + const child = watch(command, args, { encoding: "utf8" }) + let dropped = false child.stdout?.on("data", (chunk) => consume(String(chunk))) - child.on?.("error", () => {}) + child.on?.("error", () => { + if (dropped) return + dropped = true + if (onError) onError() + else watching = false + }) child.on?.("exit", () => { + if (dropped) return watching = false }) + return true } catch { - watching = false + return false + } + } + + function ensureWatch() { + if (watching) return + watching = true + const dest = ["monitor", "--session", "--dest", "org.freedesktop.Notifications"] + const takeGdbus = () => { + if (!takeWatch("gdbus", dest)) watching = false } + if (!takeWatch("stdbuf", ["-oL", "gdbus", ...dest], takeGdbus)) takeGdbus() } return { @@ -194,6 +206,7 @@ function createLinuxNotifier(options: NotifierInput): Notifier { }, close(id: number) { clicks.delete(id) + tokens.delete(id) const attempts: Array<[string, string[]]> = [ [ "gdbus", diff --git a/test/activate.test.ts b/test/activate.test.ts index 757f820..766e5c6 100644 --- a/test/activate.test.ts +++ b/test/activate.test.ts @@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test" import { activate, expandClickCommand, zedFocusUrl, zedSocketCandidates } from "../src/activate" import type { SpawnSyncFn } from "../src/notify" -type Call = { command: string; args: string[] } +type Call = { command: string; args: string[]; env?: NodeJS.ProcessEnv } function fakeSpawn(handler: (command: string, args: string[]) => { status?: number | null; error?: Error; stdout?: string } = () => ({ status: 0 })) { const calls: Call[] = [] - const spawn: SpawnSyncFn = (command, args) => { - calls.push({ command, args }) + const spawn: SpawnSyncFn = (command, args, options) => { + calls.push({ command, args, ...(options?.env ? { env: options.env } : {}) }) return handler(command, args) } return { spawn, calls } @@ -63,7 +63,9 @@ describe("activate", () => { "/home/gab", { XDG_DATA_HOME: "/tmp/xdg", PATH: "/usr/bin" }, ) - expect(calls[0]).toEqual({ command: "zed", args: ["-e", "zed://"] }) + expect(calls[0]?.command).toBe("zed") + expect(calls[0]?.args).toEqual(["-e", "zed://"]) + expect(calls[0]?.env?.XDG_ACTIVATION_TOKEN).toBe("gnome-shell/1/token") }) test("falls back to the zed CLI when no socket exists", () => { diff --git a/test/notify.test.ts b/test/notify.test.ts index 14e1b2d..7c2401e 100644 --- a/test/notify.test.ts +++ b/test/notify.test.ts @@ -14,13 +14,16 @@ function fakeSpawn(handler: (command: string, args: string[]) => { status?: numb function fakeWatch() { const listeners: Array<(chunk: string) => void> = [] + const errors: Array<() => void> = [] const watch: SpawnFn = () => ({ stdout: { on(event, cb) { if (event === "data") listeners.push(cb) }, }, - on() {}, + on(event, cb) { + if (event === "error") errors.push(() => cb()) + }, kill() {}, }) return { @@ -28,6 +31,9 @@ function fakeWatch() { emit(text: string) { for (const listener of listeners) listener(text) }, + emitError() { + for (const listener of errors) listener() + }, } } @@ -75,7 +81,7 @@ describe("createNotifier", () => { }, }) notifier.send("opencode request", "demo: bash", "critical", { sessionId: "ses_1" }) - emit("/org/freedesktop/Notifications: org.freedesktop.Notifications.ActionInvoked (uint32 9, 'default')\n") + emit('/org/freedesktop/Notifications: org.freedesktop.Notifications.ActionInvoked (uint32 9, "default")\n') expect(activated).toEqual([{ sessionId: "ses_1" }]) }) @@ -96,6 +102,8 @@ describe("createNotifier", () => { "/org/freedesktop/Notifications: org.freedesktop.Notifications.ActivationToken (uint32 9, 'gnome-shell/1/token')\n", ) expect(activated).toEqual([{ sessionId: "ses_1", activationToken: "gnome-shell/1/token" }]) + emit("/org/freedesktop/Notifications: org.freedesktop.Notifications.ActionInvoked (uint32 9, 'default')\n") + expect(activated).toHaveLength(1) }) test("line-buffers gdbus monitor so a click is not stuck in stdout", () => { @@ -112,6 +120,35 @@ describe("createNotifier", () => { }) }) + test("falls back to gdbus monitor when stdbuf is missing", () => { + const { spawn } = fakeSpawn(() => ({ status: 0, stdout: "(uint32 1,)\n" })) + const calls: Array<{ command: string; args: string[] }> = [] + const watch: SpawnFn = (command, args) => { + calls.push({ command, args }) + if (command === "stdbuf") throw new Error("missing") + return fakeWatch().watch(command, args) + } + createNotifier({ spawn, watch, platform: "linux" }).send("t", "b") + expect(calls[1]).toEqual({ + command: "gdbus", + args: ["monitor", "--session", "--dest", "org.freedesktop.Notifications"], + }) + }) + + test("falls back to gdbus monitor when stdbuf fails to spawn", () => { + const { spawn } = fakeSpawn(() => ({ status: 0, stdout: "(uint32 1,)\n" })) + const first = fakeWatch() + const second = fakeWatch() + const calls: Array<{ command: string; args: string[] }> = [] + const watch: SpawnFn = (command, args) => { + calls.push({ command, args }) + return (calls.length === 1 ? first : second).watch(command, args) + } + createNotifier({ spawn, watch, platform: "linux" }).send("t", "b") + first.emitError() + expect(calls.map((call) => call.command)).toEqual(["stdbuf", "gdbus"]) + }) + test("closes with gdbus when it succeeds", () => { const { spawn, calls } = fakeSpawn(() => ({ status: 0 })) createNotifier({ spawn, watch: fakeWatch().watch, platform: "linux" }).close(7)