From 4a72729acce2a9b4c350e610b844ddbf77d80188 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 20 May 2026 14:34:42 +0900 Subject: [PATCH] fix(plugin): run idle hooks for synthetic status idle OpenCode now treats session.status idle as the durable completion boundary, but the plugin only dispatched the synthetic session.idle through the main hook chain. Idle-only side effects such as tmux forwarding and team member idle continuations were skipped. Route synthetic idle through the same idle-only hook path used by real session.idle events and pin the behavior with a regression test. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/event.test.ts | 49 ++++++++++++++++++++++++++++++++++++++++ src/plugin/event.ts | 14 ++++++++---- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index 55a88ffa5..df3eb27d9 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -208,6 +208,55 @@ describe("createEventHandler - idle deduplication", () => { expect(onEvent.mock.calls[0]?.[0]).toEqual(idleEvent.event) }) + it("#given tmux integration enabled #when session.status reports idle #then synthetic idle forwards to tmuxSessionManager.onEvent", async () => { + //#given + const onEvent = mock<(event: EventInput["event"]) => void>(() => {}) + const eventHandler = createEventHandler({ + ctx: asEventHandlerContext({ + directory: "/tmp", + client: { + session: {}, + }, + }), + pluginConfig: asPluginConfig({ + tmux: { enabled: true }, + }), + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: createEventHandlerManagers({ + tmuxSessionManager: { + onEvent, + onSessionCreated: async () => {}, + onSessionDeleted: async () => {}, + }, + }), + hooks: createEventHandlerHooks({}), + }) + + //#when + await eventHandler(asEventHandlerInput({ + event: { + type: "session.status", + properties: { + sessionID: "ses_tmux_synthetic_idle", + status: { type: "idle" }, + }, + }, + })) + + //#then + expect(onEvent).toHaveBeenCalledTimes(1) + expect(onEvent.mock.calls[0]?.[0]).toEqual({ + type: "session.idle", + properties: { + sessionID: "ses_tmux_synthetic_idle", + synthetic: true, + }, + }) + }) + it("#given a readiness retry is pending #when session.idle arrives through the plugin handler #then tmux retry spawns the pane", async () => { //#given const sessionStatusData: Record = {} diff --git a/src/plugin/event.ts b/src/plugin/event.ts index d60222ab7..cb653f8d7 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -394,6 +394,12 @@ export function createEventHandler(args: { return hooks.sessionRecovery.handleInterruptedToolResultsOnIdle(sessionID); }; + const dispatchIdleOnlyHooks = async (input: EventInput): Promise => { + managers.tmuxSessionManager?.onEvent?.(input.event); + await runEventHookSafely("teamIdleWakeHint", teamIdleWakeHint, input); + await runEventHookSafely("teamMemberStatusHandler", teamMemberStatusHandler, input); + }; + const getFallbackContinuationKeys = (fallbackContext?: FallbackContinuationContext): FallbackContinuationDedupeKeys => { const agentKey = fallbackContext?.agentName ? getAgentConfigKey(fallbackContext.agentName).trim().toLowerCase() @@ -628,7 +634,8 @@ export function createEventHandler(args: { if (!shouldDispatchIdleEvent(sessionID, now)) { return; } - await dispatchToHooks(syntheticIdle as EventInput); + const syntheticIdleInput = syntheticIdle as EventInput; + await dispatchToHooks(syntheticIdleInput); if (pluginConfig.openclaw) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, @@ -640,6 +647,7 @@ export function createEventHandler(args: { }, }); } + await dispatchIdleOnlyHooks(syntheticIdleInput); } const { event } = input; @@ -761,9 +769,7 @@ export function createEventHandler(args: { } if (event.type === "session.idle") { - managers.tmuxSessionManager?.onEvent?.(event); - await runEventHookSafely("teamIdleWakeHint", teamIdleWakeHint, input); - await runEventHookSafely("teamMemberStatusHandler", teamMemberStatusHandler, input); + await dispatchIdleOnlyHooks(input); } if (event.type === "message.updated") {