From 96126bf5cf71b2b35820a2a22dbc40be9bf5ded7 Mon Sep 17 00:00:00 2001 From: Sergey Morozik Date: Sun, 16 Aug 2026 18:59:00 -0400 Subject: [PATCH] fix: re-emit session_start after plugin init (missed session.created race) Warp dismisses its "Install Warp Plugin" instructions only after receiving the session_start handshake, which is emitted exclusively on session.created. Because plugin init is async, session.created can fire before the plugin's event handlers are registered, so the handshake is never sent and Warp keeps showing the setup instructions even though the plugin is loaded and working (#17, #18). After init, re-emit session_start for the most recent top-level session via client.session.list(), deferred by 2s (past event-bus attach; calling client.session.list synchronously during init can hang). A dedup flag skips the re-emit when the session.created handler already sent the handshake, so no double notifications occur. Fixes #17 Refs #18, #11 Co-Authored-By: Oz --- src/index.ts | 27 +++++++ tests/deferred-session-start.test.ts | 105 +++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 tests/deferred-session-start.test.ts diff --git a/src/index.ts b/src/index.ts index c9ace13..20c8f5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,32 @@ export const WarpPlugin: Plugin = async ({ client, directory }) => { console.error("[opencode-warp] failed to emit init log:", err) }) + // `session.created` can fire before this plugin's handlers finish + // registering (plugin init is async), so Warp never sees the + // session_start handshake and keeps showing the "setup plugin" + // instructions even though the plugin is loaded (#17, #18). + // Re-emit session_start shortly after init for the most recent + // top-level session, unless the handler already sent one. + // Deferred past the event-bus attach; client.session.list can hang if + // called synchronously during plugin init. + let sessionStartSent = false + const initCwd = directory || "" + setTimeout(async () => { + if (sessionStartSent) return + try { + const result = await client.session.list() + const mainSession = result.data?.find((s) => !s.parentID) + if (!mainSession) return + sessionStartSent = true + const body = buildPayload("session_start", mainSession.id, initCwd, { + plugin_version: PLUGIN_VERSION, + }) + warpNotify(NOTIFICATION_TITLE, body) + } catch { + // best-effort handshake; ignore failures + } + }, 2000) + const subagentCache = new Map() async function isSubagentSession(sessionId?: string): Promise { @@ -102,6 +128,7 @@ export const WarpPlugin: Plugin = async ({ client, directory }) => { case "session.created": { const info = event.properties.info if (info.parentID) return + sessionStartSent = true const body = buildPayload("session_start", info.id, cwd, { plugin_version: PLUGIN_VERSION, }) diff --git a/tests/deferred-session-start.test.ts b/tests/deferred-session-start.test.ts new file mode 100644 index 0000000..84e548a --- /dev/null +++ b/tests/deferred-session-start.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test" + +const notifySpy = mock(() => {}) +mock.module("../src/notify", () => ({ + warpNotify: notifySpy, +})) + +const { WarpPlugin } = await import("../src/index") + +// Delay used by the deferred session_start emit in src/index.ts. +const DEFERRED_DELAY_MS = 2000 + +function makeClient() { + return { + app: { log: async () => ({}) }, + session: { + get: async () => ({ data: {} }), + list: async () => ({ data: [{ id: "sess-main" }] }), + messages: async () => ({ data: [] }), + }, + } as any +} + +async function waitForDeferredEmit() { + await new Promise((resolve) => setTimeout(resolve, DEFERRED_DELAY_MS + 300)) +} + +describe("deferred session_start handshake", () => { + const originalProtocol = process.env.WARP_CLI_AGENT_PROTOCOL_VERSION + + beforeEach(() => { + process.env.WARP_CLI_AGENT_PROTOCOL_VERSION = "1" + notifySpy.mockClear() + }) + + afterEach(() => { + if (originalProtocol === undefined) { + delete process.env.WARP_CLI_AGENT_PROTOCOL_VERSION + } else { + process.env.WARP_CLI_AGENT_PROTOCOL_VERSION = originalProtocol + } + }) + + it( + "re-emits session_start after init when session.created was never handled", + async () => { + await WarpPlugin({ client: makeClient(), directory: "/tmp/proj" } as any) + + // Nothing yet: the emit is deferred past plugin init. + expect(notifySpy).not.toHaveBeenCalled() + + await waitForDeferredEmit() + + expect(notifySpy).toHaveBeenCalledTimes(1) + const [title, body] = notifySpy.mock.calls[0] as [string, string] + expect(title).toBe("warp://cli-agent") + const payload = JSON.parse(body) + expect(payload.event).toBe("session_start") + expect(payload.session_id).toBe("sess-main") + expect(payload.plugin_version).toBeDefined() + }, + 10000, + ) + + it( + "does not duplicate session_start when session.created already fired", + async () => { + const handlers = (await WarpPlugin({ + client: makeClient(), + directory: "/tmp/proj", + } as any)) as any + + await handlers.event({ + event: { + type: "session.created", + properties: { info: { id: "sess-main" } }, + }, + }) + expect(notifySpy).toHaveBeenCalledTimes(1) + + // The deferred emit must be suppressed by the dedup flag. + await waitForDeferredEmit() + expect(notifySpy).toHaveBeenCalledTimes(1) + }, + 10000, + ) + + it( + "skips subagent sessions when picking the handshake session", + async () => { + const client = makeClient() + client.session.list = async () => ({ + data: [{ id: "sess-sub", parentID: "sess-main-parent" }, { id: "sess-top" }], + }) + + await WarpPlugin({ client, directory: "/tmp/proj" } as any) + await waitForDeferredEmit() + + expect(notifySpy).toHaveBeenCalledTimes(1) + const [, body] = notifySpy.mock.calls[0] as [string, string] + expect(JSON.parse(body).session_id).toBe("sess-top") + }, + 10000, + ) +})