From 4da48555ee4309c83fabfa73eaecb0766b4f82fb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 11:48:18 +0900 Subject: [PATCH 1/9] fix(plugin): normalize event session ids Handle OpenCode session events that carry the session ID under properties.info.id or properties.info.sessionID so background tasks and continuation hooks do not miss idle/error/delete events. Add regression coverage for nested session.idle events completing background tasks and waking continuation hooks. --- src/features/background-agent/manager.test.ts | 96 +++++++++++++++++++ src/features/background-agent/manager.ts | 29 +++--- .../session-idle-event-handler.ts | 8 +- src/features/tmux-subagent/manager.ts | 5 +- .../polling-manager-event-session-id.test.ts | 43 +++++++++ src/features/tmux-subagent/polling-manager.ts | 9 +- .../tmux-subagent/session-created-handler.ts | 5 +- src/hooks/agent-usage-reminder/hook.ts | 10 +- .../recovery-hook.ts | 15 +-- src/hooks/atlas/event-handler.ts | 23 ++--- src/hooks/atlas/index.test.ts | 32 +++++++ src/hooks/auto-slash-command/hook.ts | 14 +-- src/hooks/category-skill-reminder/hook.ts | 10 +- .../handlers/session-event-handler.ts | 19 ++-- src/hooks/compaction-context-injector/hook.ts | 10 +- .../compaction-context-injector/session-id.ts | 5 +- src/hooks/compaction-todo-preserver/hook.ts | 4 +- src/hooks/context-window-monitor.ts | 14 +-- src/hooks/directory-agents-injector/hook.ts | 12 +-- src/hooks/directory-readme-injector/hook.ts | 12 +-- src/hooks/interactive-bash-session/hook.ts | 4 +- src/hooks/preemptive-compaction.ts | 15 +-- src/hooks/ralph-loop/index.test.ts | 19 ++++ .../non-abort-error-continuation.test.ts | 71 ++++++++++++++ .../ralph-loop/ralph-loop-event-handler.ts | 20 ++-- src/hooks/ralph-loop/session-event-handler.ts | 11 ++- src/hooks/rules-injector/hook.ts | 10 +- src/hooks/runtime-fallback/event-handler.ts | 14 +-- .../message-update-handler.ts | 3 +- .../session-status-handler.ts | 3 +- .../session-notification-event-properties.ts | 9 ++ src/hooks/session-notification.test.ts | 41 ++++++++ src/hooks/session-notification.ts | 14 ++- src/hooks/stop-continuation-guard/hook.ts | 11 ++- src/hooks/task-reminder/hook.ts | 5 +- .../team-idle-wake-hint.ts | 4 +- .../team-lead-orphan-handler.ts | 4 +- .../team-member-error-handler.ts | 4 +- .../team-member-status-handler.ts | 7 +- src/hooks/think-mode/hook.ts | 7 +- .../todo-continuation-enforcer/handler.ts | 13 +-- .../non-idle-events.ts | 22 ++--- .../todo-continuation-enforcer.test.ts | 83 +++++++++++++--- .../unstable-agent-babysitter-hook.ts | 17 ++-- src/hooks/write-existing-file-guard/hook.ts | 4 +- src/plugin/event.test.ts | 50 ++++++++++ src/plugin/event.ts | 86 +++++++++-------- src/plugin/session-status-normalizer.ts | 4 +- src/shared/event-session-id.test.ts | 40 ++++++++ src/shared/event-session-id.ts | 23 +++++ src/shared/index.ts | 1 + 51 files changed, 740 insertions(+), 254 deletions(-) create mode 100644 src/features/tmux-subagent/polling-manager-event-session-id.test.ts create mode 100644 src/shared/event-session-id.test.ts create mode 100644 src/shared/event-session-id.ts diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 9249af41a..c28abfcad 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -5023,6 +5023,54 @@ describe("BackgroundManager.handleEvent - session.error", () => { manager.shutdown() }) + test("completes task when session.idle carries session id in info", async () => { + //#given + const sessionID = "ses-info-idle-completes-task" + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + messages: async () => ({ + data: [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "done" }], + }, + ], + }), + todo: async () => ({ data: [] }), + }, + } + + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + stubNotifyParentSession(manager) + + const task = createMockTask({ + id: "task-info-idle-completes", + sessionId: sessionID, + parentSessionId: "parent-session", + parentMessageId: "msg-info-idle", + description: "task completed by nested idle event", + agent: "explore", + status: "running", + startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)), + }) + getTaskMap(manager).set(task.id, task) + + //#when + manager.handleEvent({ + type: "session.idle", + properties: { info: { id: sessionID } }, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + //#then + expect(task.status).toBe("completed") + + manager.shutdown() + }) + test("completes task on session.status idle after todo-continuation finishes", async () => { //#given const sessionID = "ses-status-idle-after-todo-continuation" @@ -5747,6 +5795,54 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => { expect(task.progress!.toolCalls).toBe(2) }) + test("should update lastUpdate when legacy message.part.updated only has part session id", () => { + //#given - a running task with stale lastUpdate + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + + const oldUpdate = new Date(Date.now() - 300_000) + const task: BackgroundTask = { + id: "task-part-only-1", + sessionId: "session-part-only-1", + parentSessionId: "parent-1", + parentMessageId: "msg-1", + description: "Legacy part-only task", + prompt: "Keep working", + agent: "oracle", + status: "running", + startedAt: new Date(Date.now() - 600_000), + progress: { + toolCalls: 0, + lastUpdate: oldUpdate, + }, + } + getTaskMap(manager).set(task.id, task) + + //#when - a legacy message.part.updated event arrives without top-level sessionID + manager.handleEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "session-part-only-1", + type: "text", + text: "still working", + }, + }, + }) + + //#then - lastUpdate should be refreshed, toolCalls should remain 0 + expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime()) + expect(task.progress!.toolCalls).toBe(0) + }) + test("should update lastUpdate on thinking-type message.part.updated event", () => { //#given - a running task with stale lastUpdate const client = { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 3ec161b0e..fdfa5849d 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -18,6 +18,7 @@ import { resolveInheritedPromptTools, createInternalAgentTextPart, } from "../../shared" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { setSessionTools } from "../../shared/session-tools-store" import { SessionCategoryRegistry } from "../../shared/session-category-registry" @@ -118,7 +119,7 @@ interface MessagePartInfo { interface EventProperties { sessionID?: string - info?: { id?: string } + info?: { id?: string; sessionID?: string } [key: string]: unknown } @@ -1260,8 +1261,9 @@ The fallback retry session is now created and can be inspected directly. this.observedIncompleteTodosBySession.delete(sessionID) } - private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined): boolean { - if (!partInfo?.sessionID) return false + private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined, sessionID?: string): boolean { + if (!partInfo) return false + if (!partInfo.sessionID && !sessionID) return false if (partInfo.tool) return true if (partInfo.type === "tool" || partInfo.type === "tool_result") return true if (partInfo.type === "text" || partInfo.type === "reasoning") return true @@ -1279,9 +1281,9 @@ The fallback retry session is now created and can be inspected directly. const info = props?.info if (!info || typeof info !== "object") return - const sessionID = (info as Record)["sessionID"] + const sessionID = resolveMessageEventSessionID(props) const role = (info as Record)["role"] - if (typeof sessionID !== "string") return + if (!sessionID) return if (role === "tool") { this.markSessionOutputObserved(sessionID) @@ -1312,7 +1314,7 @@ The fallback retry session is now created and can be inspected directly. if (event.type === "message.part.updated" || event.type === "message.part.delta") { const partInfo = resolveMessagePartInfo(props) - const sessionID = partInfo?.sessionID + const sessionID = resolveMessageEventSessionID(props) if (!sessionID) return const resolved = this.resolveTaskAttemptBySession(sessionID) @@ -1320,7 +1322,7 @@ The fallback retry session is now created and can be inspected directly. const { task } = resolved - if (this.hasOutputSignalFromPart(partInfo)) { + if (this.hasOutputSignalFromPart(partInfo, sessionID)) { this.markSessionOutputObserved(sessionID) } @@ -1404,7 +1406,7 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "todo.updated") { - const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined + const sessionID = resolveSessionEventID(props) const todos = Array.isArray(props?.todos) ? props.todos : undefined if (!sessionID || !todos) return @@ -1419,7 +1421,7 @@ The fallback retry session is now created and can be inspected directly. if (event.type === "session.idle") { if (!props || typeof props !== "object") return - const sessionID = typeof props.sessionID === "string" ? props.sessionID : undefined + const sessionID = resolveSessionEventID(props) if (sessionID) { void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => { log("[background-agent] Failed to flush pending parent wake:", { sessionID, error }) @@ -1440,7 +1442,7 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "session.error") { - const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const resolved = this.resolveTaskAttemptBySession(sessionID) @@ -1469,9 +1471,8 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "session.deleted") { - const info = props?.info - if (!info || typeof info.id !== "string") return - const sessionID = info.id + const sessionID = resolveSessionEventID(props) + if (!sessionID) return this.clearSessionOutputObserved(sessionID) this.clearSessionTodoObservation(sessionID) @@ -1529,7 +1530,7 @@ The fallback retry session is now created and can be inspected directly. } if (event.type === "session.status") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const status = props?.status as { type?: string; message?: string } | undefined if (!sessionID || !status?.type) return diff --git a/src/features/background-agent/session-idle-event-handler.ts b/src/features/background-agent/session-idle-event-handler.ts index c3396f75d..e004b128d 100644 --- a/src/features/background-agent/session-idle-event-handler.ts +++ b/src/features/background-agent/session-idle-event-handler.ts @@ -1,12 +1,8 @@ import { log } from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" import { MIN_IDLE_TIME_MS } from "./constants" import type { BackgroundTask } from "./types" -function getString(obj: Record, key: string): string | undefined { - const value = obj[key] - return typeof value === "string" ? value : undefined -} - export function handleSessionIdleBackgroundEvent(args: { properties: Record findBySession: (sessionID: string) => BackgroundTask | undefined @@ -26,7 +22,7 @@ export function handleSessionIdleBackgroundEvent(args: { emitIdleEvent, } = args - const sessionID = getString(properties, "sessionID") + const sessionID = resolveSessionEventID(properties) if (!sessionID) return const task = findBySession(sessionID) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 212985cbd..dda4c0468 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { TmuxConfig } from "../../config/schema" import type { TrackedSession, CapacityConfig, WindowState } from "./types" import * as sharedModule from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" import { isInsideTmux as defaultIsInsideTmux, getCurrentPaneId as defaultGetCurrentPaneId, @@ -1098,9 +1099,9 @@ export class TmuxSessionManager { if (event.type !== "session.created") return const info = event.properties?.info - if (!info?.id || !info?.parentID) return + const sessionId = resolveSessionEventID(event.properties) + if (!sessionId || !info?.parentID) return - const sessionId = info.id const title = info.title ?? "Subagent" if (!this.sourcePaneId) { diff --git a/src/features/tmux-subagent/polling-manager-event-session-id.test.ts b/src/features/tmux-subagent/polling-manager-event-session-id.test.ts new file mode 100644 index 000000000..486862f8f --- /dev/null +++ b/src/features/tmux-subagent/polling-manager-event-session-id.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" + +import { TmuxPollingManager } from "./polling-manager" +import type { TrackedSession } from "./types" + +describe("TmuxPollingManager event session ids", () => { + test("#given legacy message.part.updated properties #when handling activity #then part session id increments activity version", () => { + const sessions = new Map() + sessions.set("ses-part-only", { + sessionId: "ses-part-only", + paneId: "%1", + description: "test", + createdAt: new Date(), + lastSeenAt: new Date(), + closePending: false, + closeRetryCount: 0, + activityVersion: 0, + }) + + const client = { + session: { + status: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + const manager = new TmuxPollingManager(client as never, sessions, async () => {}) + + manager.handleEvent({ + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "ses-part-only", + type: "text", + text: "working", + }, + }, + }) + + expect(sessions.get("ses-part-only")?.activityVersion).toBe(1) + }) +}) diff --git a/src/features/tmux-subagent/polling-manager.ts b/src/features/tmux-subagent/polling-manager.ts index 74e017c5e..c34e126aa 100644 --- a/src/features/tmux-subagent/polling-manager.ts +++ b/src/features/tmux-subagent/polling-manager.ts @@ -7,6 +7,7 @@ import { import type { TrackedSession } from "./types" import { log } from "../../shared" import { normalizeSDKResponse } from "../../shared" +import { resolveMessageEventSessionID } from "../../shared/event-session-id" const MIN_STABILITY_TIME_MS = 10 * 1000 const STABLE_POLLS_REQUIRED = 3 @@ -170,10 +171,7 @@ export class TmuxPollingManager { if (!properties) return undefined if (event.type === "message.updated") { - const info = properties.info - if (!info || typeof info !== "object") return undefined - const sessionId = (info as { sessionID?: unknown }).sessionID - return typeof sessionId === "string" ? sessionId : undefined + return resolveMessageEventSessionID(properties) } if ( @@ -182,8 +180,7 @@ export class TmuxPollingManager { || event.type === "message.part.removed" || event.type === "message.removed" ) { - const sessionId = properties.sessionID - return typeof sessionId === "string" ? sessionId : undefined + return resolveMessageEventSessionID(properties) } return undefined diff --git a/src/features/tmux-subagent/session-created-handler.ts b/src/features/tmux-subagent/session-created-handler.ts index fa6fcc24e..a80cdd546 100644 --- a/src/features/tmux-subagent/session-created-handler.ts +++ b/src/features/tmux-subagent/session-created-handler.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { TmuxConfig } from "../../config/schema" import type { CapacityConfig, TrackedSession } from "./types" import { log } from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" import { queryWindowState } from "./pane-state-querier" import { decideSpawnActions, type SessionMapping } from "./decision-engine" import { executeActions } from "./action-executor" @@ -44,9 +45,9 @@ export async function handleSessionCreated( if (event.type !== "session.created") return const info = event.properties?.info - if (!info?.id || !info?.parentID) return + const sessionId = resolveSessionEventID(event.properties) + if (!sessionId || !info?.parentID) return - const sessionId = info.id const title = info.title ?? "Subagent" if (deps.sessions.has(sessionId) || deps.pendingSessions.has(sessionId)) { diff --git a/src/hooks/agent-usage-reminder/hook.ts b/src/hooks/agent-usage-reminder/hook.ts index ef2a7b3d9..d5ea75ecd 100644 --- a/src/hooks/agent-usage-reminder/hook.ts +++ b/src/hooks/agent-usage-reminder/hook.ts @@ -8,6 +8,7 @@ import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants"; import type { AgentUsageState } from "./types"; import { getSessionAgent } from "../../features/claude-code-session-state"; import { getAgentConfigKey } from "../../shared/agent-display-names"; +import { resolveSessionEventID } from "../../shared/event-session-id"; interface ToolExecuteInput { tool: string; @@ -112,15 +113,14 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) { const props = event.properties as Record | undefined; if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - resetState(sessionInfo.id); + const sessionID = resolveSessionEventID(props); + if (sessionID) { + resetState(sessionID); } } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; + const sessionID = resolveSessionEventID(props); if (sessionID) { resetState(sessionID); } diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts index 0a80d63bc..862c3b8a3 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts @@ -7,6 +7,7 @@ import { executeCompact, getLastAssistant } from "./executor" import { attemptDeduplicationRecovery } from "./deduplication-recovery" import { clearSessionState } from "./state" import { clearAllSessionTimeouts, clearSessionTimeout } from "./session-timeout-map" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" export interface AnthropicContextWindowLimitRecoveryOptions { @@ -53,17 +54,17 @@ export function createAnthropicContextWindowLimitRecoveryHook( const props = event.properties as Record | undefined if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - clearSessionTimeout(pendingCompactionTimeoutBySession, sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + clearSessionTimeout(pendingCompactionTimeoutBySession, sessionID) - clearSessionState(autoCompactState, sessionInfo.id) + clearSessionState(autoCompactState, sessionID) } return } if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) dependencies.log("[auto-compact] session.error received", { sessionID, error: props?.error }) if (!sessionID) return @@ -120,7 +121,7 @@ export function createAnthropicContextWindowLimitRecoveryHook( if (event.type === "message.updated") { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) if (sessionID && info?.role === "assistant" && info.error) { dependencies.log("[auto-compact] message.updated with error", { sessionID, error: info.error }) @@ -137,7 +138,7 @@ export function createAnthropicContextWindowLimitRecoveryHook( } if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return if (!autoCompactState.pendingCompact.has(sessionID)) return diff --git a/src/hooks/atlas/event-handler.ts b/src/hooks/atlas/event-handler.ts index e9358b7ad..2ad001df4 100644 --- a/src/hooks/atlas/event-handler.ts +++ b/src/hooks/atlas/event-handler.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared/logger" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { HOOK_NAME } from "./hook-name" import { isAbortError } from "./is-abort-error" import { handleAtlasSessionIdle } from "./idle-event" @@ -17,7 +18,7 @@ export function createAtlasEventHandler(input: { const props = event.properties as Record | undefined if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const state = getState(sessionID) @@ -39,7 +40,7 @@ export function createAtlasEventHandler(input: { } if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return await handleAtlasSessionIdle({ ctx, options, getState, sessionID }) return @@ -47,7 +48,7 @@ export function createAtlasEventHandler(input: { if (event.type === "message.updated") { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const role = info?.role as string | undefined if (!sessionID) return @@ -64,7 +65,7 @@ export function createAtlasEventHandler(input: { if (event.type === "message.part.updated") { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const role = info?.role as string | undefined if (sessionID && role === "assistant") { @@ -78,7 +79,7 @@ export function createAtlasEventHandler(input: { } if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) if (sessionID) { const state = sessions.get(sessionID) if (state) { @@ -90,20 +91,20 @@ export function createAtlasEventHandler(input: { } if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - const deletedState = sessions.get(sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + const deletedState = sessions.get(sessionID) if (deletedState?.pendingRetryTimer) { clearTimeout(deletedState.pendingRetryTimer) } - sessions.delete(sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) + sessions.delete(sessionID) + log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID }) } return } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined + const sessionID = resolveSessionEventID(props) if (sessionID) { const compactedState = sessions.get(sessionID) if (compactedState?.pendingRetryTimer) { diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 9e5692e44..f3f9f5602 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -1347,6 +1347,38 @@ session_id: ses_untrusted_999 expect(callArgs.body.parts[0].text).toContain("2 remaining") }) + test("should inject continuation when idle event carries session id in info", async () => { + // given - boulder state with incomplete plan and nested session event shape + const planPath = join(TEST_DIR, "test-plan-info-idle.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2\n- [ ] Task 3") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan-info-idle", + } + writeBoulderState(TEST_DIR, state) + + const mockInput = createMockPluginInput() + const hook = createTestAtlasHook(mockInput) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { info: { id: MAIN_SESSION_ID } }, + }, + }) + + // then - should call prompt with continuation + expect(mockInput._promptMock).toHaveBeenCalled() + const callArgs = mockInput._promptMock.mock.calls[0][0] + expect(callArgs.path.id).toBe(MAIN_SESSION_ID) + expect(callArgs.body.parts[0].text).toContain("incomplete tasks") + expect(callArgs.body.parts[0].text).toContain("2 remaining") + }) + test("should settle idle before injecting boulder continuation", async () => { // given const planPath = join(TEST_DIR, "test-plan.md") diff --git a/src/hooks/auto-slash-command/hook.ts b/src/hooks/auto-slash-command/hook.ts index 73083f20d..1803394d1 100644 --- a/src/hooks/auto-slash-command/hook.ts +++ b/src/hooks/auto-slash-command/hook.ts @@ -5,6 +5,7 @@ import { } from "./detector" import { executeSlashCommand, type ExecutorOptions } from "./executor" import { log } from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" import { AUTO_SLASH_COMMAND_TAG_CLOSE, AUTO_SLASH_COMMAND_TAG_OPEN, @@ -25,16 +26,7 @@ function isRecord(value: unknown): value is Record { } function getDeletedSessionID(properties: unknown): string | null { - if (!isRecord(properties)) { - return null - } - - const info = properties.info - if (!isRecord(info)) { - return null - } - - return typeof info.id === "string" ? info.id : null + return resolveSessionEventID(properties) ?? null } function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null { @@ -49,7 +41,7 @@ function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | "commandId", ] - const recordInput = input as unknown + const recordInput: unknown = input if (!isRecord(recordInput)) { return null } diff --git a/src/hooks/category-skill-reminder/hook.ts b/src/hooks/category-skill-reminder/hook.ts index a89d182b0..f940e6288 100644 --- a/src/hooks/category-skill-reminder/hook.ts +++ b/src/hooks/category-skill-reminder/hook.ts @@ -3,6 +3,7 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" import { getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared" import { getAgentConfigKey } from "../../shared/agent-display-names" +import { resolveSessionEventID } from "../../shared/event-session-id" import { buildReminderMessage } from "./formatter" /** @@ -120,15 +121,14 @@ export function createCategorySkillReminderHook( const props = event.properties as Record | undefined if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - sessionStates.delete(sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + sessionStates.delete(sessionID) } } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined + const sessionID = resolveSessionEventID(props) if (sessionID) { sessionStates.delete(sessionID) } diff --git a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts index ca4556dda..534b39e34 100644 --- a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts +++ b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts @@ -7,6 +7,7 @@ import { clearTranscriptCache } from "../transcript" import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache" import type { PluginConfig } from "../types" import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared" +import { resolveSessionEventID } from "../../../shared/event-session-id" import { clearAllSessionHookState, clearSessionHookState, @@ -26,7 +27,7 @@ export function createSessionEventHandler( if (event.type === "session.error") { const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (sessionID) { sessionErrorState.set(sessionID, { hasError: true, @@ -38,13 +39,13 @@ export function createSessionEventHandler( if (event.type === "session.deleted") { const props = event.properties as Record | undefined - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - parentSessionIdCache.delete(sessionInfo.id) - clearTranscriptCache(sessionInfo.id) - clearToolInputCache(sessionInfo.id) - contextCollector?.clear(sessionInfo.id) - clearSessionHookState(sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + parentSessionIdCache.delete(sessionID) + clearTranscriptCache(sessionID) + clearToolInputCache(sessionID) + contextCollector?.clear(sessionID) + clearSessionHookState(sessionID) } return } @@ -54,7 +55,7 @@ export function createSessionEventHandler( } const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const claudeConfig = await loadClaudeHooksConfig() diff --git a/src/hooks/compaction-context-injector/hook.ts b/src/hooks/compaction-context-injector/hook.ts index 29e253518..9251d92c0 100644 --- a/src/hooks/compaction-context-injector/hook.ts +++ b/src/hooks/compaction-context-injector/hook.ts @@ -3,6 +3,7 @@ import { clearCompactionAgentConfigCheckpoint, setCompactionAgentConfigCheckpoint, } from "../../shared/compaction-agent-config-checkpoint" +import { resolveMessageEventSessionID } from "../../shared/event-session-id" import { log } from "../../shared/logger" import { COMPACTION_CONTEXT_PROMPT } from "./compaction-context-prompt" import { resolveSessionPromptConfig } from "./session-prompt-config-resolver" @@ -121,14 +122,15 @@ export function createCompactionContextInjector(options?: { sessionID?: string } | undefined - if (!info?.sessionID || info.role !== "assistant" || !info.id) { + const sessionID = resolveMessageEventSessionID(props) + if (!sessionID || info?.role !== "assistant" || !info.id) { return } - const tailState = getTailState(info.sessionID) + const tailState = getTailState(sessionID) if (tailState.currentMessageID && tailState.currentMessageID !== info.id) { finalizeTrackedAssistantMessage(tailState) - await maybeWarnAboutNoTextTail(info.sessionID) + await maybeWarnAboutNoTextTail(sessionID) } if (tailState.currentMessageID !== info.id) { @@ -139,7 +141,7 @@ export function createCompactionContextInjector(options?: { } if (event.type === "message.part.delta") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const messageID = props?.messageID as string | undefined const field = props?.field as string | undefined const delta = props?.delta as string | undefined diff --git a/src/hooks/compaction-context-injector/session-id.ts b/src/hooks/compaction-context-injector/session-id.ts index 29de9c5c4..e9ff8a90d 100644 --- a/src/hooks/compaction-context-injector/session-id.ts +++ b/src/hooks/compaction-context-injector/session-id.ts @@ -1,8 +1,9 @@ +import { resolveSessionEventID } from "../../shared/event-session-id" + export function isCompactionAgent(agent: string | undefined): boolean { return agent?.trim().toLowerCase() === "compaction" } export function resolveSessionID(props?: Record): string | undefined { - return (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined + return resolveSessionEventID(props) } diff --git a/src/hooks/compaction-todo-preserver/hook.ts b/src/hooks/compaction-todo-preserver/hook.ts index 2bfe20cac..63e744c9c 100644 --- a/src/hooks/compaction-todo-preserver/hook.ts +++ b/src/hooks/compaction-todo-preserver/hook.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" interface TodoSnapshot { @@ -97,8 +98,7 @@ async function resolveTodoWriter(): Promise { } function resolveSessionID(props?: Record): string | undefined { - return (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined + return resolveSessionEventID(props) } export interface CompactionTodoPreserver { diff --git a/src/hooks/context-window-monitor.ts b/src/hooks/context-window-monitor.ts index acdeee1c4..30e241c3f 100644 --- a/src/hooks/context-window-monitor.ts +++ b/src/hooks/context-window-monitor.ts @@ -4,6 +4,7 @@ import { type ContextLimitModelCacheState, } from "../shared/context-limit-resolver" import { isCompactionAgent } from "../shared/compaction-marker" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id" import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive" const CONTEXT_WARNING_THRESHOLD = 0.70 @@ -86,10 +87,10 @@ export function createContextWindowMonitorHook( const props = event.properties as Record | undefined if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - remindedSessions.delete(sessionInfo.id) - tokenCache.delete(sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + remindedSessions.delete(sessionID) + tokenCache.delete(sessionID) } } @@ -106,9 +107,10 @@ export function createContextWindowMonitorHook( if (!info || info.role !== "assistant" || !info.finish) return if (isCompactionAgent(info.agent)) return - if (!info.sessionID || !info.providerID || !info.tokens) return + const sessionID = resolveMessageEventSessionID(props) + if (!sessionID || !info.providerID || !info.tokens) return - tokenCache.set(info.sessionID, { + tokenCache.set(sessionID, { providerID: info.providerID, modelID: info.modelID ?? "", tokens: info.tokens, diff --git a/src/hooks/directory-agents-injector/hook.ts b/src/hooks/directory-agents-injector/hook.ts index c1f62208f..58279fd3d 100644 --- a/src/hooks/directory-agents-injector/hook.ts +++ b/src/hooks/directory-agents-injector/hook.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { createDynamicTruncator } from "../../shared/dynamic-truncator"; +import { resolveSessionEventID } from "../../shared/event-session-id"; import { processFilePathForAgentsInjection } from "./injector"; import { clearInjectedPaths } from "./storage"; @@ -56,16 +57,15 @@ export function createDirectoryAgentsInjectorHook( const props = event.properties as Record | undefined; if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - sessionCaches.delete(sessionInfo.id); - clearInjectedPaths(sessionInfo.id); + const sessionID = resolveSessionEventID(props); + if (sessionID) { + sessionCaches.delete(sessionID); + clearInjectedPaths(sessionID); } } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; + const sessionID = resolveSessionEventID(props); if (sessionID) { sessionCaches.delete(sessionID); clearInjectedPaths(sessionID); diff --git a/src/hooks/directory-readme-injector/hook.ts b/src/hooks/directory-readme-injector/hook.ts index 0fdab1858..a843131b8 100644 --- a/src/hooks/directory-readme-injector/hook.ts +++ b/src/hooks/directory-readme-injector/hook.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { createDynamicTruncator } from "../../shared/dynamic-truncator"; +import { resolveSessionEventID } from "../../shared/event-session-id"; import { processFilePathForReadmeInjection } from "./injector"; import { clearInjectedPaths } from "./storage"; @@ -56,16 +57,15 @@ export function createDirectoryReadmeInjectorHook( const props = event.properties as Record | undefined; if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - sessionCaches.delete(sessionInfo.id); - clearInjectedPaths(sessionInfo.id); + const sessionID = resolveSessionEventID(props); + if (sessionID) { + sessionCaches.delete(sessionID); + clearInjectedPaths(sessionID); } } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; + const sessionID = resolveSessionEventID(props); if (sessionID) { sessionCaches.delete(sessionID); clearInjectedPaths(sessionID); diff --git a/src/hooks/interactive-bash-session/hook.ts b/src/hooks/interactive-bash-session/hook.ts index 4128f903c..86aa2f101 100644 --- a/src/hooks/interactive-bash-session/hook.ts +++ b/src/hooks/interactive-bash-session/hook.ts @@ -5,6 +5,7 @@ import type { InteractiveBashSessionState } from "./types"; import { tokenizeCommand, findSubcommand, extractSessionNameFromTokens } from "./parser"; import { getOrCreateState, isOmoSession, killAllTrackedSessions } from "./state-manager"; import { subagentSessions } from "../../features/claude-code-session-state"; +import { resolveSessionEventID } from "../../shared/event-session-id"; interface ToolExecuteInput { tool: string; @@ -106,8 +107,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) { const props = event.properties as Record | undefined; if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - const sessionID = sessionInfo?.id; + const sessionID = resolveSessionEventID(props); if (sessionID) { const state = getOrCreateStateLocal(sessionID); diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index b1e46b689..28da76192 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -1,5 +1,6 @@ import type { OhMyOpenCodeConfig } from "../config" import { isCompactionAgent } from "../shared/compaction-marker" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id" import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver" import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor" @@ -48,7 +49,7 @@ export function createPreemptiveCompactionHook( const props = event.properties as Record | undefined if (event.type === "session.deleted") { - const sessionID = (props?.info as { id?: string } | undefined)?.id + const sessionID = resolveSessionEventID(props) if (sessionID) { compactionInProgress.delete(sessionID) compactedSessions.delete(sessionID) @@ -60,8 +61,7 @@ export function createPreemptiveCompactionHook( } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID as string | undefined) - ?? (props?.info as { id?: string } | undefined)?.id + const sessionID = resolveSessionEventID(props) if (sessionID) { postCompactionMonitor.onSessionCompacted(sessionID) } @@ -81,20 +81,21 @@ export function createPreemptiveCompactionHook( parts?: unknown } | undefined - if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return + const sessionID = resolveMessageEventSessionID(props) + if (!info || info.role !== "assistant" || !info.finish || !sessionID) return if (isCompactionAgent(info.agent)) return if (info.providerID && info.tokens) { - tokenCache.set(info.sessionID, { + tokenCache.set(sessionID, { providerID: info.providerID, modelID: info.modelID ?? "", tokens: info.tokens, }) } - compactedSessions.delete(info.sessionID) + compactedSessions.delete(sessionID) await postCompactionMonitor.onAssistantMessageUpdated({ - sessionID: info.sessionID, + sessionID, id: info.id, parts: info.parts, }) diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index 4bda8eb0b..172363473 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -304,6 +304,25 @@ describe("ralph-loop", () => { expect(state?.iteration).toBe(2) }) + test("should inject continuation when idle event carries session id in info", async () => { + // given - active loop state and nested session event shape + const hook = createRalphLoopHook(createMockPluginInput()) + hook.startLoop("session-info-idle", "Build a feature", { maxIterations: 10 }) + + // when - session goes idle with id under info + await hook.event({ + event: { + type: "session.idle", + properties: { info: { id: "session-info-idle" } }, + }, + }) + + // then - continuation should be injected for that session + expect(promptCalls.length).toBe(1) + expect(promptCalls[0].sessionID).toBe("session-info-idle") + expect(promptCalls[0].text).toContain("RALPH LOOP") + }) + test("should settle idle before injecting continuation", async () => { // given - active loop state with a configured idle settle delay const hook = createRalphLoopHook(createMockPluginInput(), { idleSettleMs: 25 }) diff --git a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts index 5c5f63ca7..66c51f96f 100644 --- a/src/hooks/ralph-loop/non-abort-error-continuation.test.ts +++ b/src/hooks/ralph-loop/non-abort-error-continuation.test.ts @@ -213,6 +213,77 @@ describe("ralph-loop non-abort error continuation", () => { expect(hook.getState()?.iteration).toBe(3) }) + test("continues after retry run activity from legacy message.part.updated part session id", async () => { + // given - an active loop retries a recoverable runtime error + const hook = createRalphLoopHook({ + directory: testDirectory, + project: testDirectory, + worktree: testDirectory, + serverUrl: "http://localhost:4096", + $: async () => ({}), + client: { + session: { + messages: async (options: { path: { id: string } }) => { + messagesCalls.push({ sessionID: options.path.id }) + return { data: [] } + }, + promptAsync: async (options: { + path: { id: string } + body: { parts: Array<{ type: string; text: string }> } + }) => { + promptCalls.push({ + sessionID: options.path.id, + text: options.body.parts[0]?.text ?? "", + }) + return {} + }, + prompt: async () => ({}), + }, + tui: { + showToast: async () => ({}), + }, + }, + } as never) + + hook.startLoop("session-123", "Keep working", { + messageCountAtStart: 0, + maxIterations: 5, + }) + + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID: "session-123", + error: { name: "RuntimeError" }, + }, + }, + }) + + // when - the retried run emits legacy assistant activity before any stale idle + await hook.event({ + event: { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "session-123", + type: "text", + text: "working", + }, + }, + }, + }) + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // then - the real idle is allowed to continue the loop + expect(promptCalls).toHaveLength(2) + expect(hook.getState()?.iteration).toBe(3) + }) + test("skips immediate runtime retry while background tasks are running", async () => { // given - an active loop owns running background work const hook = createRalphLoopHook({ diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 87c0f9435..c64cf2ef0 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared/logger" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import type { RalphLoopOptions, RalphLoopState } from "./types" import { HOOK_NAME } from "./constants" import { handleDetectedCompletion } from "./completion-handler" @@ -36,12 +37,6 @@ function hasRunningBackgroundTasks( : false } -function getInfoSessionID(props: Record | undefined): string | undefined { - const info = props?.info as Record | undefined - const sessionID = info?.sessionID - return typeof sessionID === "string" ? sessionID : undefined -} - function getRuntimeRetryActivitySessionID( eventType: string, props: Record | undefined, @@ -49,20 +44,19 @@ function getRuntimeRetryActivitySessionID( if (eventType === "message.updated") { const info = props?.info as Record | undefined const role = info?.role - return role === "assistant" ? getInfoSessionID(props) : undefined + return role === "assistant" ? resolveMessageEventSessionID(props) : undefined } if (eventType === "message.part.updated") { - if (typeof props?.sessionID === "string") return props.sessionID - return getInfoSessionID(props) + return resolveMessageEventSessionID(props) } if (eventType === "message.part.delta") { - return typeof props?.sessionID === "string" ? props.sessionID : undefined + return resolveMessageEventSessionID(props) } if (eventType === "tool.execute.before" || eventType === "tool.execute.after") { - return typeof props?.sessionID === "string" ? props.sessionID : undefined + return resolveMessageEventSessionID(props) } return undefined @@ -198,7 +192,7 @@ export function createRalphLoopEventHandler( } if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return if (inFlightSessions.has(sessionID)) { @@ -389,7 +383,7 @@ export function createRalphLoopEventHandler( } if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const error = props?.error if (!sessionID || isAbortError(error)) { handleErroredLoopSession(props, options.loopState) diff --git a/src/hooks/ralph-loop/session-event-handler.ts b/src/hooks/ralph-loop/session-event-handler.ts index 193a89de9..ee85e7f3a 100644 --- a/src/hooks/ralph-loop/session-event-handler.ts +++ b/src/hooks/ralph-loop/session-event-handler.ts @@ -1,4 +1,5 @@ import { log } from "../../shared/logger" +import { resolveSessionEventID } from "../../shared/event-session-id" import { HOOK_NAME } from "./constants" import type { RalphLoopState } from "./types" @@ -11,13 +12,13 @@ export function handleDeletedLoopSession( props: Record | undefined, loopState: LoopStateController, ): boolean { - const sessionInfo = props?.info as { id?: string } | undefined - if (!sessionInfo?.id) return false + const sessionID = resolveSessionEventID(props) + if (!sessionID) return false const state = loopState.getState() - if (state?.session_id === sessionInfo.id) { + if (state?.session_id === sessionID) { loopState.clear() - log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id }) + log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID }) } return true } @@ -26,7 +27,7 @@ export function handleErroredLoopSession( props: Record | undefined, loopState: LoopStateController, ): boolean { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const error = props?.error as { name?: string } | undefined if (error?.name === "MessageAbortedError") { diff --git a/src/hooks/rules-injector/hook.ts b/src/hooks/rules-injector/hook.ts index b3a09ca12..3b62d5e01 100644 --- a/src/hooks/rules-injector/hook.ts +++ b/src/hooks/rules-injector/hook.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { createDynamicTruncator } from "../../shared/dynamic-truncator"; +import { resolveSessionEventID } from "../../shared/event-session-id"; import { getRuleInjectionFilePath } from "./output-path"; import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache"; import { createRuleInjectionProcessor } from "./injector"; @@ -80,16 +81,15 @@ export function createRulesInjectorHook( const props = event.properties as Record | undefined; if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id) { - clearSessionState(sessionInfo.id); + const sessionID = resolveSessionEventID(props); + if (sessionID) { + clearSessionState(sessionID); } clearProjectRootCache(); } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? - (props?.info as { id?: string } | undefined)?.id) as string | undefined; + const sessionID = resolveSessionEventID(props); if (sessionID) { clearSessionState(sessionID); } diff --git a/src/hooks/runtime-fallback/event-handler.ts b/src/hooks/runtime-fallback/event-handler.ts index 5776041ed..d874c5c15 100644 --- a/src/hooks/runtime-fallback/event-handler.ts +++ b/src/hooks/runtime-fallback/event-handler.ts @@ -10,6 +10,7 @@ import { isAbortError } from "../../shared/is-abort-error" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" import { createSessionStatusHandler } from "./session-status-handler" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, sessionStatusRetryKeys } = deps @@ -30,7 +31,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { const handleSessionCreated = (props: Record | undefined) => { const sessionInfo = props?.info as { id?: string; model?: string } | undefined - const sessionID = sessionInfo?.id + const sessionID = resolveSessionEventID(props) const model = sessionInfo?.model if (sessionID && model) { @@ -41,8 +42,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { } const handleSessionDeleted = (props: Record | undefined) => { - const sessionInfo = props?.info as { id?: string } | undefined - const sessionID = sessionInfo?.id + const sessionID = resolveSessionEventID(props) if (sessionID) { log(`[${HOOK_NAME}] Cleaning up session state`, { sessionID }) @@ -58,7 +58,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { } const handleSessionStop = async (props: Record | undefined) => { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return if (sessionRetryInFlight.has(sessionID) || sessionAwaitingFallbackResult.has(sessionID)) { @@ -73,7 +73,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { const handleMessageUpdated = (props: Record | undefined) => { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const role = info?.role as string | undefined if (!sessionID || role !== "user") return @@ -81,7 +81,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { } const handleSessionIdle = (props: Record | undefined) => { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return if (cancelledSessions.has(sessionID)) { @@ -111,7 +111,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) { } const handleSessionError = async (props: Record | undefined) => { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const error = props?.error const agent = props?.agent as string | undefined diff --git a/src/hooks/runtime-fallback/message-update-handler.ts b/src/hooks/runtime-fallback/message-update-handler.ts index 0c07405a0..b054ca5d8 100644 --- a/src/hooks/runtime-fallback/message-update-handler.ts +++ b/src/hooks/runtime-fallback/message-update-handler.ts @@ -8,6 +8,7 @@ import { getFallbackModelsForSession } from "./fallback-models" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" import { hasVisibleAssistantResponse } from "./visible-assistant-response" +import { resolveMessageEventSessionID } from "../../shared/event-session-id" export { hasVisibleAssistantResponse } from "./visible-assistant-response" @@ -17,7 +18,7 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel return async (props: Record | undefined) => { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const timeoutEnabled = config.timeout_seconds > 0 const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined const infoParts = info?.parts as Array<{ type?: string; text?: string }> | undefined diff --git a/src/hooks/runtime-fallback/session-status-handler.ts b/src/hooks/runtime-fallback/session-status-handler.ts index 1fff2a6ff..c356f2f6f 100644 --- a/src/hooks/runtime-fallback/session-status-handler.ts +++ b/src/hooks/runtime-fallback/session-status-handler.ts @@ -8,6 +8,7 @@ import { getFallbackModelsForSession } from "./fallback-models" import { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils" import { resolveFallbackBootstrapModel } from "./fallback-bootstrap-model" import { dispatchFallbackRetry } from "./fallback-retry-dispatcher" +import { resolveSessionEventID } from "../../shared/event-session-id" export function createSessionStatusHandler( deps: HookDeps, @@ -22,7 +23,7 @@ export function createSessionStatusHandler( } = deps return async (props: Record | undefined) => { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) const status = props?.status as { type?: string; message?: string; attempt?: number } | undefined const agent = props?.agent as string | undefined const model = props?.model as string | undefined diff --git a/src/hooks/session-notification-event-properties.ts b/src/hooks/session-notification-event-properties.ts index b51edf81b..e3e4205dc 100644 --- a/src/hooks/session-notification-event-properties.ts +++ b/src/hooks/session-notification-event-properties.ts @@ -23,6 +23,15 @@ export function getSessionID(properties: EventProperties): string | undefined { const infoSessionId = info?.sessionId if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId + const part = properties?.part + if (isRecord(part)) { + const partSessionID = part.sessionID + if (typeof partSessionID === "string" && partSessionID.length > 0) return partSessionID + + const partSessionId = part.sessionId + if (typeof partSessionId === "string" && partSessionId.length > 0) return partSessionId + } + return undefined } diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index ceb2c981b..31a133f9a 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -375,6 +375,47 @@ describe("session-notification", () => { expect(notificationCalls).toHaveLength(0) }) + test("should mark session activity on message.part.updated event with part session id", async () => { + // given - main session is set + const mainSessionID = "main-part-activity" + setMainSession(mainSessionID) + + const hook = createSessionNotification(createMockPluginInput(), { + idleConfirmationDelay: 50, + skipIfIncompleteTodos: false, + activityGracePeriodMs: 0, + }) + + // when - session goes idle, then streamed assistant activity fires + await hook({ + event: { + type: "session.idle", + properties: { sessionID: mainSessionID }, + }, + }) + + await hook({ + event: { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: mainSessionID, + type: "text", + text: "still working", + }, + }, + }, + }) + + // Wait for idle delay to pass + await new Promise((resolve) => setTimeout(resolve, 100)) + + // then - notification should NOT be sent (streaming activity cancelled it) + expect(notificationCalls).toHaveLength(0) + }) + test("should mark session activity on tool.execute.before event", async () => { // given - main session is set const mainSessionID = "main-tool" diff --git a/src/hooks/session-notification.ts b/src/hooks/session-notification.ts index c9178f0df..e65f358cd 100644 --- a/src/hooks/session-notification.ts +++ b/src/hooks/session-notification.ts @@ -7,6 +7,7 @@ import { getEventToolName, getQuestionText, getSessionID } from "./session-notif import { hasIncompleteTodos } from "./session-todo-status" import { createIdleNotificationScheduler } from "./session-notification-scheduler" import { createSessionNotificationInit } from "./session-notification-init" +import { resolveSessionEventID } from "../shared/event-session-id" interface SessionNotificationConfig { title?: string @@ -98,8 +99,7 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif const props = event.properties as Record | undefined if (event.type === "session.created") { - const info = props?.info as Record | undefined - const sessionID = info?.id as string | undefined + const sessionID = resolveSessionEventID(props) if (sessionID) scheduler.markSessionActivity(sessionID) return } @@ -116,7 +116,11 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif return } - if (event.type === "message.updated") { + if ( + event.type === "message.updated" || + event.type === "message.part.updated" || + event.type === "message.part.delta" + ) { const info = props?.info as Record | undefined const sessionID = getSessionID({ ...props, info }) if (sessionID) scheduler.markSessionActivity(sessionID) @@ -165,8 +169,8 @@ export function createSessionNotification(ctx: PluginInput, config: SessionNotif } if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) scheduler.deleteSession(sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) scheduler.deleteSession(sessionID) } } } diff --git a/src/hooks/stop-continuation-guard/hook.ts b/src/hooks/stop-continuation-guard/hook.ts index ce3ba7c0b..94c78893e 100644 --- a/src/hooks/stop-continuation-guard/hook.ts +++ b/src/hooks/stop-continuation-guard/hook.ts @@ -5,6 +5,7 @@ import { clearContinuationMarker, setContinuationMarkerSource, } from "../../features/run-continuation-state" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" const HOOK_NAME = "stop-continuation-guard" @@ -86,11 +87,11 @@ export function createStopContinuationGuardHook( const props = event.properties as Record | undefined if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - clear(sessionInfo.id) - clearContinuationMarker(ctx.directory, sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + clear(sessionID) + clearContinuationMarker(ctx.directory, sessionID) + log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID }) } } } diff --git a/src/hooks/task-reminder/hook.ts b/src/hooks/task-reminder/hook.ts index 4e795018d..9a09daeaa 100644 --- a/src/hooks/task-reminder/hook.ts +++ b/src/hooks/task-reminder/hook.ts @@ -1,5 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" +import { resolveSessionEventID } from "../../shared/event-session-id" + const TASK_TOOLS = new Set([ "task", "task_create", @@ -50,8 +52,7 @@ export function createTaskReminderHook(_ctx: PluginInput) { "tool.execute.after": toolExecuteAfter, event: async ({ event }: { event: { type: string; properties?: unknown } }) => { if (event.type !== "session.deleted") return - const props = event.properties as { info?: { id?: string } } | undefined - const sessionId = props?.info?.id + const sessionId = resolveSessionEventID(event.properties) if (!sessionId) return sessionCounters.delete(sessionId) }, diff --git a/src/hooks/team-session-events/team-idle-wake-hint.ts b/src/hooks/team-session-events/team-idle-wake-hint.ts index 0bb99ab23..8d2c8a5d9 100644 --- a/src/hooks/team-session-events/team-idle-wake-hint.ts +++ b/src/hooks/team-session-events/team-idle-wake-hint.ts @@ -7,6 +7,7 @@ import { applyMemberSessionRouting, buildMemberPromptBody, } from "../../features/team-mode/member-session-routing" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" import { settleAfterSessionIdle } from "../shared/session-idle-settle" @@ -35,8 +36,7 @@ export type HookImpl = (input: HookInput) => Promise type TeamIdleWakeHintOptions = { idleSettleMs?: number } function getIdleSessionID(properties: unknown): string | undefined { - const record = properties as { sessionID?: string } | undefined - return record?.sessionID + return resolveSessionEventID(properties) } function buildWakeHint(unreadCount: number): string { diff --git a/src/hooks/team-session-events/team-lead-orphan-handler.ts b/src/hooks/team-session-events/team-lead-orphan-handler.ts index e7b70b3d5..07349ab32 100644 --- a/src/hooks/team-session-events/team-lead-orphan-handler.ts +++ b/src/hooks/team-session-events/team-lead-orphan-handler.ts @@ -3,14 +3,14 @@ import type { BackgroundManager } from "../../features/background-agent/manager" import { lookupTeamSession } from "../../features/team-mode/team-session-registry" import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store" import type { TmuxSessionManager } from "../../features/tmux-subagent/manager" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" type HookInput = { event: { type: string; properties?: unknown } } export type HookImpl = (input: HookInput) => Promise function getDeletedSessionID(properties: unknown): string | undefined { - const record = properties as { info?: { id?: string } } | undefined - return record?.info?.id + return resolveSessionEventID(properties) } async function findLeadTeamRunId( diff --git a/src/hooks/team-session-events/team-member-error-handler.ts b/src/hooks/team-session-events/team-member-error-handler.ts index 89dc67601..c7f669c8c 100644 --- a/src/hooks/team-session-events/team-member-error-handler.ts +++ b/src/hooks/team-session-events/team-member-error-handler.ts @@ -1,14 +1,14 @@ import type { TeamModeConfig } from "../../config/schema/team-mode" import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution" import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" type HookInput = { event: { type: string; properties?: unknown } } export type HookImpl = (input: HookInput) => Promise function getErroredSessionID(properties: unknown): string | undefined { - const record = properties as { sessionID?: string } | undefined - return record?.sessionID + return resolveSessionEventID(properties) } export function createTeamMemberErrorHandler(config: TeamModeConfig): HookImpl { diff --git a/src/hooks/team-session-events/team-member-status-handler.ts b/src/hooks/team-session-events/team-member-status-handler.ts index 3e31173c7..011b9769c 100644 --- a/src/hooks/team-session-events/team-member-status-handler.ts +++ b/src/hooks/team-session-events/team-member-status-handler.ts @@ -2,6 +2,7 @@ import type { TeamModeConfig } from "../../config/schema/team-mode" import { findResolvedMemberSession } from "../../features/team-mode/member-session-resolution" import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store" import type { RuntimeStateMember } from "../../features/team-mode/types" +import { resolveSessionEventID } from "../../shared/event-session-id" import { log } from "../../shared/logger" type HookInput = { event: { type: string; properties?: unknown } } @@ -13,13 +14,11 @@ const IDLE_TRANSITION_SOURCE_STATUSES: ReadonlySet = new Set(["run const COMPLETED_TRANSITION_SOURCE_STATUSES: ReadonlySet = new Set(["running", "idle", "pending"]) function getSessionIDFromIdleEvent(properties: unknown): string | undefined { - const record = properties as { sessionID?: string } | undefined - return record?.sessionID + return resolveSessionEventID(properties) } function getSessionIDFromDeletedEvent(properties: unknown): string | undefined { - const record = properties as { info?: { id?: string } } | undefined - return record?.info?.id + return resolveSessionEventID(properties) } async function transitionMemberStatus( diff --git a/src/hooks/think-mode/hook.ts b/src/hooks/think-mode/hook.ts index 8f2442382..732096b28 100644 --- a/src/hooks/think-mode/hook.ts +++ b/src/hooks/think-mode/hook.ts @@ -2,6 +2,7 @@ import { detectThinkKeyword, extractPromptText } from "./detector" import { isAlreadyHighVariant } from "./switcher" import type { ThinkModeState } from "./types" import { log } from "../../shared" +import { resolveSessionEventID } from "../../shared/event-session-id" const thinkModeState = new Map() @@ -66,9 +67,9 @@ export function createThinkModeHook() { event: async ({ event }: { event: { type: string; properties?: unknown } }) => { if (event.type === "session.deleted") { - const props = event.properties as { info?: { id?: string } } | undefined - if (props?.info?.id) { - thinkModeState.delete(props.info.id) + const sessionID = resolveSessionEventID(event.properties) + if (sessionID) { + thinkModeState.delete(sessionID) } } }, diff --git a/src/hooks/todo-continuation-enforcer/handler.ts b/src/hooks/todo-continuation-enforcer/handler.ts index 27096056f..0cc85fba8 100644 --- a/src/hooks/todo-continuation-enforcer/handler.ts +++ b/src/hooks/todo-continuation-enforcer/handler.ts @@ -5,6 +5,7 @@ import { clearContinuationMarker, } from "../../features/run-continuation-state" import { log } from "../../shared/logger" +import { resolveSessionEventID } from "../../shared/event-session-id" import { DEFAULT_SKIP_AGENTS, HOOK_NAME } from "./constants" import { armCompactionGuard } from "./compaction-guard" @@ -71,7 +72,7 @@ export function createTodoContinuationHandler(args: { const props = event.properties as Record | undefined if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const error = extractSessionErrorInfo(props?.error) @@ -102,7 +103,7 @@ export function createTodoContinuationHandler(args: { } if (event.type === "session.idle") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return sessionStateStore.startPruneInterval() @@ -118,7 +119,7 @@ export function createTodoContinuationHandler(args: { } if (event.type === "session.compacted") { - const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined + const sessionID = resolveSessionEventID(props) if (sessionID) { const state = sessionStateStore.getState(sessionID) const compactionEpoch = armCompactionGuard(state, Date.now()) @@ -129,9 +130,9 @@ export function createTodoContinuationHandler(args: { } if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) { - clearContinuationMarker(ctx.directory, sessionInfo.id) + const sessionID = resolveSessionEventID(props) + if (sessionID) { + clearContinuationMarker(ctx.directory, sessionID) } } diff --git a/src/hooks/todo-continuation-enforcer/non-idle-events.ts b/src/hooks/todo-continuation-enforcer/non-idle-events.ts index a88da8773..dc21fa8f1 100644 --- a/src/hooks/todo-continuation-enforcer/non-idle-events.ts +++ b/src/hooks/todo-continuation-enforcer/non-idle-events.ts @@ -1,4 +1,5 @@ import { log } from "../../shared/logger" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { COUNTDOWN_GRACE_PERIOD_MS, HOOK_NAME } from "./constants" import type { SessionStateStore } from "./session-state" @@ -12,7 +13,7 @@ export function handleNonIdleEvent(args: { if (eventType === "message.updated") { const info = properties?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(properties) const role = info?.role as string | undefined if (!sessionID) return @@ -50,12 +51,7 @@ export function handleNonIdleEvent(args: { } if (eventType === "message.part.updated") { - const sessionID = typeof properties?.sessionID === "string" - ? properties.sessionID - : undefined - const legacyInfo = properties?.info as Record | undefined - const legacySessionID = legacyInfo?.sessionID as string | undefined - const targetSessionID = sessionID ?? legacySessionID + const targetSessionID = resolveMessageEventSessionID(properties) if (targetSessionID) { const state = sessionStateStore.getExistingState(targetSessionID) @@ -69,7 +65,7 @@ export function handleNonIdleEvent(args: { } if (eventType === "message.part.delta") { - const sessionID = properties?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(properties) if (sessionID) { const state = sessionStateStore.getExistingState(sessionID) if (state) { @@ -83,7 +79,7 @@ export function handleNonIdleEvent(args: { } if (eventType === "tool.execute.before" || eventType === "tool.execute.after") { - const sessionID = properties?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(properties) if (sessionID) { const state = sessionStateStore.getExistingState(sessionID) if (state) { @@ -97,10 +93,10 @@ export function handleNonIdleEvent(args: { } if (eventType === "session.deleted") { - const sessionInfo = properties?.info as { id?: string } | undefined - if (sessionInfo?.id) { - sessionStateStore.cleanup(sessionInfo.id) - log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id }) + const sessionID = resolveSessionEventID(properties) + if (sessionID) { + sessionStateStore.cleanup(sessionID) + log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID }) } return } diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index 03b56ba4e..bb1a56d39 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -12,6 +12,7 @@ import { } from "./constants" type TimerCallback = (...args: any[]) => void +type FakeTimerID = number & ReturnType & ReturnType interface FakeTimers { advanceBy: (ms: number, advanceClock?: boolean) => Promise @@ -57,7 +58,7 @@ function createFakeTimers(): FakeTimers { callback, args, }) - return id + return id as FakeTimerID } const clear = (id: number | undefined) => { @@ -74,7 +75,7 @@ function createFakeTimers(): FakeTimers { if (normalized >= REAL_MAX_DELAY_MS) { return original.setTimeout(callback, delay, ...args) } - return schedule(callback, normalized, null, args) as unknown as ReturnType + return schedule(callback, normalized, null, args) }) as typeof setTimeout globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => { @@ -85,7 +86,7 @@ function createFakeTimers(): FakeTimers { if (interval >= REAL_MAX_DELAY_MS) { return original.setInterval(callback, delay, ...args) } - return schedule(callback, interval, interval, args) as unknown as ReturnType + return schedule(callback, interval, interval, args) }) as typeof setInterval globalThis.clearTimeout = ((id?: Parameters[0]) => { @@ -184,6 +185,8 @@ describe("todo-continuation-enforcer", () => { } } + type MockPluginInput = Parameters[0] + let mockMessages: MockMessage[] = [] function createMockPluginInput() { @@ -225,7 +228,7 @@ describe("todo-continuation-enforcer", () => { }, }, directory: "/tmp/test", - } as any + } as MockPluginInput } function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager { @@ -233,7 +236,7 @@ describe("todo-continuation-enforcer", () => { getTasksByParentSession: () => runningTasks ? [{ status: "running" }] : [], - } as any + } as BackgroundManager } beforeEach(() => { @@ -302,6 +305,26 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls[0].text).toContain("TODO CONTINUATION") }, { timeout: 15000 }) + test("should inject continuation when idle event carries session id in info", async () => { + fakeTimers.restore() + // given - OpenCode session events can nest the session id under info + const sessionID = "main-info-idle" + setMainSession(sessionID) + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) + + // when - session goes idle with the nested event shape + await hook.handler({ + event: { type: "session.idle", properties: { info: { id: sessionID } } }, + }) + + // then - continuation is still injected for that session + await wait(2500) + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0].sessionID).toBe(sessionID) + expect(promptCalls[0].text).toContain("TODO CONTINUATION") + }, { timeout: 15000 }) + test("should not inject when all todos are complete", async () => { // given - session with all todos complete const sessionID = "main-456" @@ -527,6 +550,42 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(0) }) + test("should cancel countdown on assistant activity when message.part.updated only has part session id", async () => { + // given - session starting countdown + const sessionID = "main-assistant-part-only" + setMainSession(sessionID) + + const hook = createTodoContinuationEnforcer(createMockPluginInput(), {}) + + // when - session goes idle + await hook.handler({ + event: { type: "session.idle", properties: { sessionID } }, + }) + + // when - legacy part-only sync payload reports assistant output + await fakeTimers.advanceBy(500) + await hook.handler({ + event: { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID, + type: "text", + text: "working", + }, + time: Date.now(), + }, + }, + }) + + await fakeTimers.advanceBy(3000) + + // then - no continuation injected (cancelled) + expect(promptCalls).toHaveLength(0) + }) + test("should cancel countdown on assistant activity with message.part.delta payload", async () => { // given - session starting countdown const sessionID = "main-assistant-delta" @@ -1599,7 +1658,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, { backgroundManager: createMockBackgroundManager(false), @@ -1660,7 +1719,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, { backgroundManager: createMockBackgroundManager(false), @@ -1712,7 +1771,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, {}) @@ -1769,7 +1828,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, { backgroundManager: createMockBackgroundManager(false), @@ -1823,7 +1882,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, {}) @@ -1878,7 +1937,7 @@ describe("todo-continuation-enforcer", () => { tui: { showToast: async () => ({}) }, }, directory: "/tmp/test", - } as any + } as MockPluginInput const hook = createTodoContinuationEnforcer(mockInput, { skipAgents: [], @@ -2122,7 +2181,7 @@ describe("todo-continuation-enforcer", () => { const mockInput = createMockPluginInput() mockInput.client.session.promptAsync = async () => { const error = new Error("prompt is too long: 150000 tokens > 100000 maximum") - ;(error as any).name = "ContextLengthError" + error.name = "ContextLengthError" throw error } diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts index c5168759d..6f43a7840 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -2,6 +2,7 @@ import type { BackgroundManager } from "../../features/background-agent" import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" +import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" import { isAbortError } from "../../shared/is-abort-error" import { buildReminder, @@ -128,7 +129,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option const props = event.properties as Record | undefined if (event.type === "session.error") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID || !isAbortError(props?.error)) return cancelledSessions.add(sessionID) @@ -138,7 +139,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option } if (event.type === "session.stop") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return cancelledSessions.add(sessionID) @@ -149,7 +150,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option if (event.type === "message.updated") { const info = props?.info as Record | undefined - const sessionID = info?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) const role = info?.role as string | undefined if (!sessionID || (role !== "user" && role !== "assistant")) return @@ -158,7 +159,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option } if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveMessageEventSessionID(props) if (!sessionID) return cancelledSessions.delete(sessionID) @@ -166,16 +167,16 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option } if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (!sessionInfo?.id) return + const sessionID = resolveSessionEventID(props) + if (!sessionID) return - cancelledSessions.delete(sessionInfo.id) + cancelledSessions.delete(sessionID) return } if (event.type !== "session.idle") return - const sessionID = props?.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return const mainSessionID = getMainSessionID() diff --git a/src/hooks/write-existing-file-guard/hook.ts b/src/hooks/write-existing-file-guard/hook.ts index 19b72eb73..96da6fa02 100644 --- a/src/hooks/write-existing-file-guard/hook.ts +++ b/src/hooks/write-existing-file-guard/hook.ts @@ -4,6 +4,7 @@ import { existsSync, realpathSync } from "fs" import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path" import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler" +import { resolveSessionEventID } from "../../shared/event-session-id" export type GuardArgs = { filePath?: string @@ -108,8 +109,7 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput, options?: Wri return } - const props = event.properties as { info?: { id?: string } } | undefined - const sessionID = props?.info?.id + const sessionID = resolveSessionEventID(event.properties) if (!sessionID) { return } diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index a400118e6..a89f59ce9 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -802,6 +802,56 @@ describe("createEventHandler - event forwarding", () => { expect(forwardedEvents[0]?.event.type).toBe("message.part.delta") }) + it("forwards legacy message.part.updated activity with part-only session id to tmux session manager", async () => { + const forwardedEvents: EventInput[] = [] + const eventHandler = createEventHandler({ + ctx: asEventHandlerContext({}), + pluginConfig: asPluginConfig({ + tmux: { + enabled: true, + layout: "main-vertical", + main_pane_size: 60, + main_pane_min_width: 120, + agent_pane_min_width: 40, + isolation: "inline", + }, + }), + firstMessageVariantGate: { + markSessionCreated: () => {}, + clear: () => {}, + }, + managers: createEventHandlerManagers({ + skillMcpManager: { + disconnectSession: async () => {}, + }, + tmuxSessionManager: { + onEvent: (event: EventInput["event"]) => { + forwardedEvents.push({ event }) + }, + onSessionCreated: async () => {}, + onSessionDeleted: async () => {}, + }, + }), + hooks: createEventHandlerHooks({}), + }) + await eventHandler(asEventHandlerInput({ + event: { + type: "message.part.updated", + properties: { + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "ses_tmux_part_only", + type: "text", + text: "x", + }, + }, + }, + })) + expect(forwardedEvents.length).toBe(1) + expect(forwardedEvents[0]?.event.type).toBe("message.part.updated") + }) + it("does not forward tmux activity events when tmux integration is disabled", async () => { const forwardedEvents: EventInput[] = [] const eventHandler = createEventHandler({ diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 2b94fcadf..ade05ea93 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -47,6 +47,7 @@ import type { CreatedHooks } from "../create-hooks"; import type { Managers } from "../create-managers"; import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles"; import { normalizeSessionStatusToIdle } from "./session-status-normalizer"; +import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"; type FirstMessageVariantGate = { markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void; @@ -235,15 +236,15 @@ export function createEventHandler(args: { const getEventSessionID = (input: EventInput): string | undefined => { const properties = input.event.properties; - if ( - !properties || - typeof properties !== "object" || - !("sessionID" in properties) || - typeof properties.sessionID !== "string" - ) { - return undefined; + if (input.event.type.startsWith("session.")) { + return resolveSessionEventID(properties); } - return properties.sessionID; + if (input.event.type.startsWith("message.") || input.event.type.startsWith("tool.")) { + return resolveMessageEventSessionID(properties); + } + const record: Record | undefined = isRecord(properties) ? properties : undefined; + const sessionID = record?.sessionID; + return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : undefined; }; const runEventHookSafely = async ( @@ -467,10 +468,11 @@ export function createEventHandler(args: { if (event.type === "session.created") { const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined; - const isSubagentSession = !!sessionInfo?.parentID || !!sessionInfo?.id && subagentSessions.has(sessionInfo.id); + const sessionID = resolveSessionEventID(props); + const isSubagentSession = !!sessionInfo?.parentID || !!sessionID && subagentSessions.has(sessionID); if (!isSubagentSession) { - setMainSession(sessionInfo?.id); + setMainSession(sessionID); } firstMessageVariantGate.markSessionCreated(sessionInfo); @@ -489,62 +491,62 @@ export function createEventHandler(args: { // Skip subagent sessions — they are dispatched by specialized callbacks // in create-managers.ts (async) and tool-registry.ts (sync) - if (pluginConfig.openclaw && sessionInfo?.id && !isSubagentSession) { + if (pluginConfig.openclaw && sessionID && !isSubagentSession) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, rawEvent: event.type, context: { - sessionId: sessionInfo.id, + sessionId: sessionID, projectPath: pluginContext.directory, - tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionInfo.id) ?? process.env.TMUX_PANE, + tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE, }, }); } } if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined; - if (sessionInfo?.id === getMainSessionID()) { + const sessionID = resolveSessionEventID(props); + if (sessionID === getMainSessionID()) { setMainSession(undefined); } - if (sessionInfo?.id) { - const wasSyncSubagentSession = syncSubagentSessions.has(sessionInfo.id); - clearSessionAgent(sessionInfo.id); - lastHandledModelErrorMessageID.delete(sessionInfo.id); - lastHandledRetryStatusKey.delete(sessionInfo.id); - lastKnownModelBySession.delete(sessionInfo.id); + if (sessionID) { + const wasSyncSubagentSession = syncSubagentSessions.has(sessionID); + clearSessionAgent(sessionID); + lastHandledModelErrorMessageID.delete(sessionID); + lastHandledRetryStatusKey.delete(sessionID); + lastKnownModelBySession.delete(sessionID); if (modelFallback) { - clearPendingModelFallback(modelFallback, sessionInfo.id); - clearSessionFallbackChain(modelFallback, sessionInfo.id); + clearPendingModelFallback(modelFallback, sessionID); + clearSessionFallbackChain(modelFallback, sessionID); } - resetMessageCursor(sessionInfo.id); - clearBackgroundOutputConsumptionsForParentSession(sessionInfo.id); - clearBackgroundOutputConsumptionsForTaskSession(sessionInfo.id); - firstMessageVariantGate.clear(sessionInfo.id); - clearSessionModel(sessionInfo.id); - clearSessionPromptParams(sessionInfo.id); - syncSubagentSessions.delete(sessionInfo.id); + resetMessageCursor(sessionID); + clearBackgroundOutputConsumptionsForParentSession(sessionID); + clearBackgroundOutputConsumptionsForTaskSession(sessionID); + firstMessageVariantGate.clear(sessionID); + clearSessionModel(sessionID); + clearSessionPromptParams(sessionID); + syncSubagentSessions.delete(sessionID); if (pluginConfig.openclaw) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, rawEvent: event.type, context: { - sessionId: sessionInfo.id, + sessionId: sessionID, projectPath: pluginContext.directory, - tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionInfo.id) ?? process.env.TMUX_PANE, + tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE, }, }); } if (wasSyncSubagentSession) { - subagentSessions.delete(sessionInfo.id); + subagentSessions.delete(sessionID); } - deleteSessionTools(sessionInfo.id); - await managers.skillMcpManager.disconnectSession(sessionInfo.id); + deleteSessionTools(sessionID); + await managers.skillMcpManager.disconnectSession(sessionID); await lspManager.cleanupTempDirectoryClients(); if (tmuxIntegrationEnabled) { await managers.tmuxSessionManager.onSessionDeleted({ - sessionID: sessionInfo.id, + sessionID, }); } } @@ -555,12 +557,12 @@ export function createEventHandler(args: { if (event.type === "message.removed") { const messageID = props?.messageID as string | undefined; - const sessionID = props?.sessionID as string | undefined; + const sessionID = resolveMessageEventSessionID(props); restoreBackgroundOutputConsumption(sessionID, messageID); } if (event.type === "session.idle" && pluginConfig.openclaw) { - const sessionID = props?.sessionID as string | undefined; + const sessionID = resolveSessionEventID(props); if (sessionID) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, @@ -582,7 +584,7 @@ export function createEventHandler(args: { if (event.type === "message.updated") { const info = props?.info as Record | undefined; - const sessionID = info?.sessionID as string | undefined; + const sessionID = resolveMessageEventSessionID(props); const agent = info?.agent as string | undefined; const role = info?.role as string | undefined; if (sessionID && info?.finish === true) { @@ -665,7 +667,7 @@ export function createEventHandler(args: { } if (event.type === "session.status") { - const sessionID = props?.sessionID as string | undefined; + const sessionID = resolveSessionEventID(props); const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined; // Retry dedupe lifecycle: set key when a retry status is handled, clear it after recovery @@ -733,7 +735,7 @@ export function createEventHandler(args: { if (event.type === "session.error") { try { - const sessionID = props?.sessionID as string | undefined; + const sessionID = resolveSessionEventID(props); const error = props?.error; const errorName = extractErrorName(error); @@ -818,7 +820,7 @@ export function createEventHandler(args: { } } } catch (err) { - const sessionID = props?.sessionID as string | undefined; + const sessionID = resolveSessionEventID(props); log("[event] model-fallback error in session.error:", { sessionID, error: err }); } diff --git a/src/plugin/session-status-normalizer.ts b/src/plugin/session-status-normalizer.ts index e02377d3c..6089bf5c8 100644 --- a/src/plugin/session-status-normalizer.ts +++ b/src/plugin/session-status-normalizer.ts @@ -1,3 +1,5 @@ +import { resolveSessionEventID } from "../shared/event-session-id" + type EventInput = { event: { type: string; properties?: Record } } type SessionStatus = { type: string } @@ -10,7 +12,7 @@ export function normalizeSessionStatusToIdle(input: EventInput): EventInput | nu const status = props.status as SessionStatus | undefined if (!status || status.type !== "idle") return null - const sessionID = props.sessionID as string | undefined + const sessionID = resolveSessionEventID(props) if (!sessionID) return null return { diff --git a/src/shared/event-session-id.test.ts b/src/shared/event-session-id.test.ts new file mode 100644 index 000000000..a1fa9c220 --- /dev/null +++ b/src/shared/event-session-id.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" + +import { resolveMessageEventSessionID, resolveSessionEventID } from "./event-session-id" + +describe("event session id resolvers", () => { + test("#given legacy message.part.updated properties #when resolving message session id #then part.sessionID is used", () => { + const sessionID = resolveMessageEventSessionID({ + part: { + id: "part-1", + messageID: "msg-1", + sessionID: "ses-part-only", + type: "text", + text: "working", + }, + }) + + expect(sessionID).toBe("ses-part-only") + }) + + test("#given message.updated info id #when resolving message session id #then message id is not mistaken for session id", () => { + const sessionID = resolveMessageEventSessionID({ + info: { + id: "msg-not-session", + role: "assistant", + }, + }) + + expect(sessionID).toBeUndefined() + }) + + test("#given legacy session lifecycle properties #when resolving session id #then info.id is used", () => { + const sessionID = resolveSessionEventID({ + info: { + id: "ses-legacy-info-id", + }, + }) + + expect(sessionID).toBe("ses-legacy-info-id") + }) +}) diff --git a/src/shared/event-session-id.ts b/src/shared/event-session-id.ts new file mode 100644 index 000000000..734ff5d6a --- /dev/null +++ b/src/shared/event-session-id.ts @@ -0,0 +1,23 @@ +import { isRecord } from "./record-type-guard" + +function getStringField(record: Record | undefined, key: string): string | undefined { + const value = record?.[key] + return typeof value === "string" && value.length > 0 ? value : undefined +} + +export function resolveSessionEventID(properties: unknown): string | undefined { + const props = isRecord(properties) ? properties : undefined + const info = isRecord(props?.info) ? props.info : undefined + return getStringField(props, "sessionID") + ?? getStringField(info, "sessionID") + ?? getStringField(info, "id") +} + +export function resolveMessageEventSessionID(properties: unknown): string | undefined { + const props = isRecord(properties) ? properties : undefined + const info = isRecord(props?.info) ? props.info : undefined + const part = isRecord(props?.part) ? props.part : undefined + return getStringField(props, "sessionID") + ?? getStringField(info, "sessionID") + ?? getStringField(part, "sessionID") +} diff --git a/src/shared/index.ts b/src/shared/index.ts index e99234c33..0ea008554 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -54,6 +54,7 @@ export * from "./fallback-model-availability" export * from "./connected-providers-cache" export * from "./context-limit-resolver" export * from "./session-utils" +export * from "./event-session-id" export * from "./tmux" export * from "./model-suggestion-retry" export * from "./opencode-server-auth" From 4394f34225bfc128a264702c547921a1ed2efe92 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 2/9] feat(shim): add bun-file/hash/which shims with Node fallbacks The plugin builds with `bun build --target bun` and runs under OpenCode CLI (Bun SEA) but also under OpenCode Desktop (Electron / Node V8) where `globalThis.Bun` does not exist. Mirror the existing `bun-spawn-shim.ts` pattern for three more Bun runtime APIs: - bun-file-shim: bunFile()/bunWrite() backed by node:fs/promises with ArrayBuffer slicing to avoid Node Buffer pool exposure - bun-hash-shim: pure-JS XXH32, bit-exact with Bun.hash.xxHash32 verified by 1200-pair fuzz comparison so existing hashline LINE#ID tags remain stable across runtimes - bun-which-shim: synchronous PATH walker with Windows .exe/.cmd/.bat/.com extensions plus isUnsafeCommandName guard that rejects path separators, parent traversal, drive letters and null bytes before any probe Each shim uses the canonical `runtime.Bun !== undefined` detection and delegates to native Bun under IS_BUN, otherwise uses Node primitives. Each ships with a co-located test that exercises both branches via the `node:vm.runInNewContext` pattern from bun-hash-shim.test.ts. --- src/shared/bun-file-shim.test.ts | 300 ++++++++++++++++++++++++++++++ src/shared/bun-file-shim.ts | 65 +++++++ src/shared/bun-hash-shim.test.ts | 175 +++++++++++++++++ src/shared/bun-hash-shim.ts | 89 +++++++++ src/shared/bun-which-shim.test.ts | 149 +++++++++++++++ src/shared/bun-which-shim.ts | 58 ++++++ 6 files changed, 836 insertions(+) create mode 100644 src/shared/bun-file-shim.test.ts create mode 100644 src/shared/bun-file-shim.ts create mode 100644 src/shared/bun-hash-shim.test.ts create mode 100644 src/shared/bun-hash-shim.ts create mode 100644 src/shared/bun-which-shim.test.ts create mode 100644 src/shared/bun-which-shim.ts diff --git a/src/shared/bun-file-shim.test.ts b/src/shared/bun-file-shim.test.ts new file mode 100644 index 000000000..7be9fa4d7 --- /dev/null +++ b/src/shared/bun-file-shim.test.ts @@ -0,0 +1,300 @@ +/// + +import { Buffer as NodeBuffer } from "node:buffer" +import { readFileSync } from "node:fs" +import { access, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { runInNewContext } from "node:vm" +import { afterAll, beforeAll, describe, expect, it } from "bun:test" + +import { bunFile, bunWrite } from "./bun-file-shim" + +type NodeFallbackBunFileLike = { + text(): Promise + arrayBuffer(): Promise + exists(): Promise + delete(): Promise +} + +type NodeFallbackBunFile = (path: string) => NodeFallbackBunFileLike +type NodeFallbackBunWrite = (path: string, data: string | ArrayBuffer | Uint8Array) => Promise + +type NodeFallbackExports = { + bunFile: NodeFallbackBunFile + bunWrite: NodeFallbackBunWrite +} + +type BunFileTestRuntime = { + Transpiler: new (options: { loader: "ts" }) => { transformSync(source: string): string } +} + +type BunFileSandbox = { + access: typeof access + Buffer: typeof NodeBuffer + console: Console + Promise: PromiseConstructor + readFile: typeof readFile + TextEncoder: typeof TextEncoder + Uint8Array: Uint8ArrayConstructor + unlink: typeof unlink + writeFile: typeof writeFile + __exports?: NodeFallbackExports +} + +const runtime = globalThis as typeof globalThis & { Bun: BunFileTestRuntime } +const NODE_FALLBACK = loadNodeFallbackBunFileShim() + +let temporaryDirectory = "" +let nodeFallbackTemporaryDirectory = "" + +function temporaryPath(fileName: string): string { + return join(temporaryDirectory, fileName) +} + +function nodeFallbackPath(fileName: string): string { + return join(nodeFallbackTemporaryDirectory, fileName) +} + +function loadNodeFallbackBunFileShim(): NodeFallbackExports { + const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-file-shim.ts") + const source = readFileSync(sourcePath, "utf8") + const importStatement = 'import { access, readFile, unlink, writeFile } from "node:fs/promises"\n\n' + const interfaceSignature = "export interface BunFileLike {" + const bunFileSignature = "export function bunFile(path: string): BunFileLike {" + const bunWriteSignature = + "export async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise {" + + if (!source.startsWith(importStatement)) { + throw new Error("bun-file-shim import statement changed") + } + + for (const signature of [interfaceSignature, bunFileSignature, bunWriteSignature]) { + if (!source.includes(signature)) { + throw new Error(`bun-file-shim signature changed: ${signature}`) + } + } + + const transformedSource = source + .slice(importStatement.length) + .replace(interfaceSignature, "interface BunFileLike {") + .replace(bunFileSignature, "function bunFile(path: string): BunFileLike {") + .replace( + bunWriteSignature, + "async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise {", + ) + const scriptSource = `${transformedSource}\nglobalThis.__exports = { bunFile, bunWrite }\n` + const transpiler = new runtime.Bun.Transpiler({ loader: "ts" }) + const script = transpiler.transformSync(scriptSource) + const sandbox: BunFileSandbox = { + access, + Buffer: NodeBuffer, + console, + Promise, + readFile, + TextEncoder, + Uint8Array, + unlink, + writeFile, + } + + runInNewContext(script, sandbox, { filename: sourcePath }) + + if (!sandbox.__exports) { + throw new Error("Node fallback bun-file-shim loader failed") + } + + return sandbox.__exports +} + +function arrayBufferFromBytes(bytes: number[]): ArrayBuffer { + const arrayBuffer = new ArrayBuffer(bytes.length) + const view = new Uint8Array(arrayBuffer) + + view.set(bytes) + + return arrayBuffer +} + +describe("bun-file-shim", () => { + beforeAll(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "bun-file-shim-")) + }) + + afterAll(async () => { + if (temporaryDirectory.length === 0) return + + await rm(temporaryDirectory, { recursive: true, force: true }) + }) + + describe("#given bunFile", () => { + it("#when text is called then it reads file contents", async () => { + const filePath = temporaryPath("text.txt") + const content = "hello from file" + + await writeFile(filePath, content) + + expect(await bunFile(filePath).text()).toBe(content) + }) + + it("#when arrayBuffer is called then it returns exact file bytes", async () => { + const filePath = temporaryPath("bytes.bin") + const bytes = new Uint8Array([0, 1, 2, 255]) + + await writeFile(filePath, bytes) + + const arrayBuffer = await bunFile(filePath).arrayBuffer() + + expect(arrayBuffer.byteLength).toBe(bytes.byteLength) + expect(Array.from(new Uint8Array(arrayBuffer))).toEqual(Array.from(bytes)) + }) + + it("#when exists is called then it reflects file presence", async () => { + const existingPath = temporaryPath("existing.txt") + const missingPath = temporaryPath("missing.txt") + + await writeFile(existingPath, "present") + + expect(await bunFile(existingPath).exists()).toBe(true) + expect(await bunFile(missingPath).exists()).toBe(false) + }) + + it("#when delete is called then it removes the file", async () => { + const filePath = temporaryPath("delete-me.txt") + + await writeFile(filePath, "remove") + await bunFile(filePath).delete() + + expect(await bunFile(filePath).exists()).toBe(false) + }) + }) + + describe("#given bunWrite", () => { + it("#when writing string data then it writes contents and returns byte count", async () => { + const filePath = temporaryPath("write-string.txt") + const content = "write me" + const bytesWritten = await bunWrite(filePath, content) + + expect(bytesWritten).toBe(new TextEncoder().encode(content).byteLength) + expect(await readFile(filePath, "utf8")).toBe(content) + }) + + it("#when writing array buffer data then it writes exact bytes", async () => { + const filePath = temporaryPath("write-array-buffer.bin") + const arrayBuffer = arrayBufferFromBytes([65, 66, 67, 68]) + const bytesWritten = await bunWrite(filePath, arrayBuffer) + const written = await readFile(filePath) + + expect(bytesWritten).toBe(arrayBuffer.byteLength) + expect(Array.from(written)).toEqual([65, 66, 67, 68]) + }) + + it("#when writing then reading text then it round trips content", async () => { + const filePath = temporaryPath("round-trip.txt") + const content = "round trip content" + + await bunWrite(filePath, content) + + expect(await bunFile(filePath).text()).toBe(content) + }) + + it("#when writing unicode text then it round trips content", async () => { + const filePath = temporaryPath("unicode-round-trip.txt") + const content = "Hello 世界 🌍" + + await bunWrite(filePath, content) + + expect(await bunFile(filePath).text()).toBe(content) + }) + }) + + describe("#given Node fallback without Bun global", () => { + beforeAll(async () => { + nodeFallbackTemporaryDirectory = await mkdtemp(join(tmpdir(), "bun-file-shim-node-")) + }) + + afterAll(async () => { + if (nodeFallbackTemporaryDirectory.length === 0) return + + await rm(nodeFallbackTemporaryDirectory, { recursive: true, force: true }) + }) + + it("#when text is called then it reads file contents", async () => { + const filePath = nodeFallbackPath("text.txt") + const content = "hello from Node fallback" + + await writeFile(filePath, content) + + expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content) + }) + + it("#when arrayBuffer is called then it returns exact file bytes", async () => { + const filePath = nodeFallbackPath("bytes.bin") + const bytes = new Uint8Array([0, 1, 2, 255, 128]) + + await writeFile(filePath, bytes) + + const arrayBuffer = await NODE_FALLBACK.bunFile(filePath).arrayBuffer() + + expect(arrayBuffer.byteLength).toBe(bytes.byteLength) + expect(Array.from(new Uint8Array(arrayBuffer))).toEqual(Array.from(bytes)) + }) + + it("#when exists is called then it reflects file presence", async () => { + const existingPath = nodeFallbackPath("existing.txt") + const missingPath = nodeFallbackPath("missing.txt") + + await writeFile(existingPath, "present") + + expect(await NODE_FALLBACK.bunFile(existingPath).exists()).toBe(true) + expect(await NODE_FALLBACK.bunFile(missingPath).exists()).toBe(false) + }) + + it("#when delete is called then it removes the file", async () => { + const filePath = nodeFallbackPath("delete-me.txt") + + await writeFile(filePath, "remove") + await NODE_FALLBACK.bunFile(filePath).delete() + + expect(await NODE_FALLBACK.bunFile(filePath).exists()).toBe(false) + }) + + it("#when writing string data then it writes contents and returns byte count", async () => { + const filePath = nodeFallbackPath("write-string.txt") + const content = "write me from Node fallback" + const bytesWritten = await NODE_FALLBACK.bunWrite(filePath, content) + + expect(bytesWritten).toBe(new TextEncoder().encode(content).byteLength) + expect(await readFile(filePath, "utf8")).toBe(content) + }) + + it("#when writing array buffer data then it writes exact bytes", async () => { + const filePath = nodeFallbackPath("write-array-buffer.bin") + const arrayBuffer = arrayBufferFromBytes([65, 66, 67, 68, 69]) + const bytesWritten = await NODE_FALLBACK.bunWrite(filePath, arrayBuffer) + const written = await readFile(filePath) + + expect(bytesWritten).toBe(arrayBuffer.byteLength) + expect(Array.from(written)).toEqual([65, 66, 67, 68, 69]) + }) + + it("#when writing then reading text then it round trips content", async () => { + const filePath = nodeFallbackPath("round-trip.txt") + const content = "round trip through Node fallback" + + await NODE_FALLBACK.bunWrite(filePath, content) + + expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content) + }) + + it("#when writing unicode text then it round trips content", async () => { + const filePath = nodeFallbackPath("unicode-round-trip.txt") + const content = "Hello 世界 🌍 from Node fallback" + + await NODE_FALLBACK.bunWrite(filePath, content) + + expect(await NODE_FALLBACK.bunFile(filePath).text()).toBe(content) + }) + }) +}) diff --git a/src/shared/bun-file-shim.ts b/src/shared/bun-file-shim.ts new file mode 100644 index 000000000..970853184 --- /dev/null +++ b/src/shared/bun-file-shim.ts @@ -0,0 +1,65 @@ +import { access, readFile, unlink, writeFile } from "node:fs/promises" + +export interface BunFileLike { + text(): Promise + arrayBuffer(): Promise + exists(): Promise + delete(): Promise +} + +type BunFileRuntime = { + file(path: string): BunFileLike + write(path: string, data: string | ArrayBuffer | Uint8Array): Promise +} + +const runtime = globalThis as typeof globalThis & { Bun?: BunFileRuntime } +const IS_BUN = typeof runtime.Bun !== "undefined" + +function byteLength(data: string | ArrayBuffer | Uint8Array): number { + if (typeof data === "string") return Buffer.byteLength(data, "utf8") + + return data.byteLength +} + +function toWritableData(data: string | ArrayBuffer | Uint8Array): string | Uint8Array { + if (typeof data === "string") return data + if (data instanceof Uint8Array) return data + + return new Uint8Array(data) +} + +function createNodeFile(path: string): BunFileLike { + return { + text() { + return readFile(path, "utf8") + }, + async arrayBuffer() { + const buffer = await readFile(path) + + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + }, + exists() { + return access(path).then( + () => true, + () => false, + ) + }, + delete() { + return unlink(path) + }, + } +} + +export function bunFile(path: string): BunFileLike { + if (IS_BUN) return runtime.Bun!.file(path) + + return createNodeFile(path) +} + +export async function bunWrite(path: string, data: string | ArrayBuffer | Uint8Array): Promise { + if (IS_BUN) return runtime.Bun!.write(path, data) + + await writeFile(path, toWritableData(data)) + + return byteLength(data) +} diff --git a/src/shared/bun-hash-shim.test.ts b/src/shared/bun-hash-shim.test.ts new file mode 100644 index 000000000..417553907 --- /dev/null +++ b/src/shared/bun-hash-shim.test.ts @@ -0,0 +1,175 @@ +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { runInNewContext } from "node:vm" +import { describe, expect, test } from "bun:test" +import { bunHashXxh32 as runtimeBunHashXxh32 } from "./bun-hash-shim" + +type HashFunction = (input: string, seed: number) => number +type HashPair = { input: string; seed: number } +type BunHashTestRuntime = { + hash: { xxHash32(data: string | Uint8Array, seed: number): number } + Transpiler: new (options: { loader: "ts" }) => { transformSync(source: string): string } +} +type HashSandbox = { + Math: Math + TextEncoder: typeof TextEncoder + Uint8Array: Uint8ArrayConstructor + __bunHashShim?: { bunHashXxh32: HashFunction } +} + +const runtime = globalThis as typeof globalThis & { Bun: BunHashTestRuntime } +const FUZZ_PAIR_COUNT = 1_200 +const FIXED_LENGTHS = [0, 1, 2, 3, 4, 15, 16, 17, 31, 32, 33, 64, 100, 255, 500] +const FIXED_SEEDS = [0, 1, 42, 12345, 0xdeadbeef, 0xffffffff] +const CONTENT_FRAGMENTS = ["你好世界", "\u{1f389}", "\u{1f525}", "\n", "\r\n", "\t", " "] +const SPECIAL_INPUTS = [ + "", + " ", + "\t\n\r\n", + "hello world", + "你好世界", + "\u{1f389}\u{1f525}", + "mixed 你好 \u{1f389} ascii", + "line one\nline two\r\n\tindented", +] +const PURE_JS_HASH = loadPureJsBunHashXxh32() +const FUZZ_PAIRS = createFuzzPairs() + +function loadPureJsBunHashXxh32(): HashFunction { + const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-hash-shim.ts") + const source = readFileSync(sourcePath, "utf8") + const exportSignature = "export function bunHashXxh32(input: string, seed: number): number {" + + if (!source.includes(exportSignature)) { + throw new Error("bunHashXxh32 export signature changed") + } + + const scriptSource = `${source.replace( + exportSignature, + "function bunHashXxh32(input: string, seed: number): number {", + )}\nglobalThis.__bunHashShim = { bunHashXxh32 }\n` + const transpiler = new runtime.Bun.Transpiler({ loader: "ts" }) + const script = transpiler.transformSync(scriptSource) + const sandbox: HashSandbox = { Math, TextEncoder, Uint8Array } + + runInNewContext(script, sandbox, { filename: sourcePath }) + + const pureJsHash = sandbox.__bunHashShim?.bunHashXxh32 + if (!pureJsHash) { + throw new Error("pure-JS bunHashXxh32 loader failed") + } + + return pureJsHash +} + +function createUint32Generator(seed: number): () => number { + let state = seed >>> 0 + + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + + return state + } +} + +function createSeed(pairIndex: number, nextUint32: () => number): number { + if (pairIndex % (FIXED_SEEDS.length + 1) === FIXED_SEEDS.length) return nextUint32() + + return FIXED_SEEDS[pairIndex % FIXED_SEEDS.length] ?? 0 +} + +function createRandomString(length: number, nextUint32: () => number): string { + let value = "" + + while (value.length < length) { + if (nextUint32() % 10 < 6) { + value += String.fromCharCode(32 + (nextUint32() % 95)) + continue + } + + const fragment = CONTENT_FRAGMENTS[nextUint32() % CONTENT_FRAGMENTS.length] ?? " " + if (value.length + fragment.length <= length) { + value += fragment + continue + } + + value += String.fromCharCode(32 + (nextUint32() % 95)) + } + + return value +} + +function createFuzzPairs(): HashPair[] { + const nextUint32 = createUint32Generator(0x5eed1234) + const pairs: HashPair[] = [] + + for (const input of SPECIAL_INPUTS) { + pairs.push({ input, seed: createSeed(pairs.length, nextUint32) }) + } + + for (const length of FIXED_LENGTHS) { + pairs.push({ input: createRandomString(length, nextUint32), seed: createSeed(pairs.length, nextUint32) }) + } + + while (pairs.length < FUZZ_PAIR_COUNT) { + const randomLength = nextUint32() % 501 + const length = pairs.length % 13 === 0 ? (FIXED_LENGTHS[pairs.length % FIXED_LENGTHS.length] ?? randomLength) : randomLength + pairs.push({ input: createRandomString(length, nextUint32), seed: createSeed(pairs.length, nextUint32) }) + } + + return pairs +} + +function nativeXxh32(input: string, seed: number): number { + return runtime.Bun.hash.xxHash32(input, seed) +} + +function createMismatchMessage(label: string, input: string, seed: number, expected: number, actual: number): string { + return `${label} mismatch for input=${JSON.stringify(input)} seed=${seed} expected=${expected} actual=${actual}` +} + +function expectPureJsHashToMatchBun(label: string, input: string, seed: number): void { + const expected = nativeXxh32(input, seed) + const actual = PURE_JS_HASH(input, seed) + + if (actual !== expected) { + throw new Error(createMismatchMessage(label, input, seed, expected, actual)) + } +} + +describe("#given known XXH32 test vectors", () => { + test("#when pure-JS hash is called #then returns canonical values", () => { + expect(PURE_JS_HASH("", 0)).toBe(0x02cc5d05) + expect(PURE_JS_HASH("a", 0)).toBe(0x550d7456) + expect(PURE_JS_HASH("abc", 0)).toBe(0x32d153ff) + }) + + test("#when a non-zero seed is used #then matches Bun hash", () => { + expectPureJsHashToMatchBun("seeded vector", "test", 42) + expect(runtimeBunHashXxh32("test", 42)).toBe(nativeXxh32("test", 42)) + }) +}) + +describe("#given random inputs #when hashed with pure-JS and Bun.hash", () => { + test("#then all fuzz pairs are bit-exact", () => { + expect(FUZZ_PAIRS).toHaveLength(FUZZ_PAIR_COUNT) + + for (const [pairIndex, pair] of FUZZ_PAIRS.entries()) { + expectPureJsHashToMatchBun(`fuzz pair ${pairIndex}`, pair.input, pair.seed) + } + }) +}) + +describe("#given production-like inputs", () => { + test("#when hashed with line-number seeds #then pure-JS matches Bun hash", () => { + const inputs = [" const x = 42;", "import { foo } from 'bar'", "// comment", ""] + const seeds = [0, 1, 50, 100, 999] + + for (const input of inputs) { + for (const seed of seeds) { + expectPureJsHashToMatchBun("production-like input", input, seed) + } + } + }) +}) diff --git a/src/shared/bun-hash-shim.ts b/src/shared/bun-hash-shim.ts new file mode 100644 index 000000000..d77bbda10 --- /dev/null +++ b/src/shared/bun-hash-shim.ts @@ -0,0 +1,89 @@ +type BunHashRuntime = { hash: { xxHash32(data: string | Uint8Array, seed: number): number } } + +const runtime = globalThis as typeof globalThis & { Bun?: BunHashRuntime } +const IS_BUN = typeof runtime.Bun !== "undefined" +const encoder = new TextEncoder() + +const PRIME32_1 = 0x9e3779b1 +const PRIME32_2 = 0x85ebca77 +const PRIME32_3 = 0xc2b2ae3d +const PRIME32_4 = 0x27d4eb2f +const PRIME32_5 = 0x165667b1 + +function rotateLeft32(value: number, bits: number): number { + return ((value << bits) | (value >>> (32 - bits))) >>> 0 +} + +function readUint32LittleEndian(input: Uint8Array, offset: number): number { + return ( + ((input[offset] ?? 0) | + ((input[offset + 1] ?? 0) << 8) | + ((input[offset + 2] ?? 0) << 16) | + ((input[offset + 3] ?? 0) << 24)) >>> + 0 + ) +} + +function round32(accumulator: number, value: number): number { + const added = (accumulator + Math.imul(value, PRIME32_2)) >>> 0 + + return Math.imul(rotateLeft32(added, 13), PRIME32_1) >>> 0 +} + +function xxHash32Js(input: Uint8Array, seed: number): number { + let offset = 0 + const length = input.length + let hash: number + + if (length >= 16) { + const limit = length - 16 + let value1 = (seed + PRIME32_1 + PRIME32_2) >>> 0 + let value2 = (seed + PRIME32_2) >>> 0 + let value3 = seed >>> 0 + let value4 = (seed - PRIME32_1) >>> 0 + + while (offset <= limit) { + value1 = round32(value1, readUint32LittleEndian(input, offset)) + offset += 4 + value2 = round32(value2, readUint32LittleEndian(input, offset)) + offset += 4 + value3 = round32(value3, readUint32LittleEndian(input, offset)) + offset += 4 + value4 = round32(value4, readUint32LittleEndian(input, offset)) + offset += 4 + } + + hash = (rotateLeft32(value1, 1) + rotateLeft32(value2, 7)) >>> 0 + hash = (hash + rotateLeft32(value3, 12)) >>> 0 + hash = (hash + rotateLeft32(value4, 18)) >>> 0 + } else { + hash = (seed + PRIME32_5) >>> 0 + } + + hash = (hash + length) >>> 0 + + while (offset + 4 <= length) { + hash = (hash + Math.imul(readUint32LittleEndian(input, offset), PRIME32_3)) >>> 0 + hash = Math.imul(rotateLeft32(hash, 17), PRIME32_4) >>> 0 + offset += 4 + } + + while (offset < length) { + hash = (hash + Math.imul(input[offset] ?? 0, PRIME32_5)) >>> 0 + hash = Math.imul(rotateLeft32(hash, 11), PRIME32_1) >>> 0 + offset += 1 + } + + hash = (hash ^ (hash >>> 15)) >>> 0 + hash = Math.imul(hash, PRIME32_2) >>> 0 + hash = (hash ^ (hash >>> 13)) >>> 0 + hash = Math.imul(hash, PRIME32_3) >>> 0 + + return (hash ^ (hash >>> 16)) >>> 0 +} + +export function bunHashXxh32(input: string, seed: number): number { + if (IS_BUN) return runtime.Bun!.hash.xxHash32(input, seed) + + return xxHash32Js(encoder.encode(input), seed >>> 0) +} diff --git a/src/shared/bun-which-shim.test.ts b/src/shared/bun-which-shim.test.ts new file mode 100644 index 000000000..9b55fec8b --- /dev/null +++ b/src/shared/bun-which-shim.test.ts @@ -0,0 +1,149 @@ +import { accessSync, constants, readFileSync } from "node:fs" +import { delimiter, dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { runInNewContext } from "node:vm" +import { describe, expect, test } from "bun:test" + +import { bunWhich } from "./bun-which-shim" + +type BunWhichFunction = (commandName: string) => string | null +type BunWhichRuntime = { + Transpiler?: new (options: { loader: "ts" }) => { transformSync(source: string): string } + which(commandName: string): string | null +} +type SandboxProcess = { + env: { PATH?: string; Path?: string } + platform: typeof process.platform +} +type BunWhichSandbox = { + accessSync: typeof accessSync + constants: typeof constants + console: Console + delimiter: typeof delimiter + join: typeof join + process: SandboxProcess + __bunWhichShim?: { bunWhich: BunWhichFunction } +} + +const runtime = globalThis as typeof globalThis & { Bun?: BunWhichRuntime } +const PATH_TRAVERSAL_COMMAND_NAMES = [ + "../etc/passwd", + "/etc/passwd", + "./tool", + "sub/dir/tool", + "C:\\Windows\\evil", + "C:tool", + ".", + "..", + "node..evil", +] +const NULL_BYTE_COMMAND_NAME = "node\0evil" +const NODE_FALLBACK_BUN_WHICH = loadNodeFallbackBunWhich() + +function loadNodeFallbackBunWhich(): BunWhichFunction { + const sourcePath = join(dirname(fileURLToPath(import.meta.url)), "bun-which-shim.ts") + const source = readFileSync(sourcePath, "utf8") + const fsImport = 'import { accessSync, constants } from "node:fs"\n' + const pathImport = 'import { delimiter, join } from "node:path"\n' + const exportSignature = "export function bunWhich(commandName: string): string | null {" + + if (!source.includes(fsImport) || !source.includes(pathImport) || !source.includes(exportSignature)) { + throw new Error("bunWhich source shape changed") + } + + const scriptSource = `${source + .replace(fsImport, "") + .replace(pathImport, "") + .replace(exportSignature, "function bunWhich(commandName: string): string | null {")}\nglobalThis.__bunWhichShim = { bunWhich }\n` + const transpilerConstructor = runtime.Bun?.Transpiler + if (!transpilerConstructor) { + throw new Error("Bun Transpiler unavailable") + } + + const transpiler = new transpilerConstructor({ loader: "ts" }) + const script = transpiler.transformSync(scriptSource) + const sandboxProcess: SandboxProcess = { + env: { PATH: process.env.PATH, Path: process.env.Path }, + platform: process.platform, + } + const sandbox: BunWhichSandbox = { accessSync, constants, console, delimiter, join, process: sandboxProcess } + + runInNewContext(script, sandbox, { filename: sourcePath }) + + const nodeFallbackBunWhich = sandbox.__bunWhichShim?.bunWhich + if (!nodeFallbackBunWhich) { + throw new Error("Node fallback bunWhich loader failed") + } + + return nodeFallbackBunWhich +} + +describe("bunWhich", () => { + test("#given 'node' command #when resolved #then returns a non-null path ending in 'node'", () => { + const resolvedPath = bunWhich("node") + + expect(resolvedPath).not.toBeNull() + expect(resolvedPath?.toLowerCase()).toMatch(/node(?:\.exe)?$/) + }) + + test("#given a non-existent command #when resolved #then returns null", () => { + const resolvedPath = bunWhich("this-command-definitely-does-not-exist-abc123xyz") + + expect(resolvedPath).toBeNull() + }) + + test("#given an empty string #when resolved #then returns null", () => { + const resolvedPath = bunWhich("") + + expect(resolvedPath).toBeNull() + }) + + test("#given the result for 'node' #when resolved #then the returned path matches Bun.which('node')", () => { + const nativePath = runtime.Bun?.which("node") + const shimPath = bunWhich("node") + + expect(nativePath).not.toBeNull() + expect(shimPath).toBe(nativePath) + }) + + test("#given path-traversal command names #when resolved through Bun runtime #then returns null", () => { + for (const commandName of PATH_TRAVERSAL_COMMAND_NAMES) { + expect(bunWhich(commandName)).toBeNull() + } + }) + + test("#given a null-byte command name #when resolved through Bun runtime #then returns null", () => { + expect(bunWhich(NULL_BYTE_COMMAND_NAME)).toBeNull() + }) +}) + +describe("#given Node fallback bunWhich loaded without Bun global", () => { + test("#when 'node' command is resolved #then returns a non-null path ending in 'node'", () => { + const resolvedPath = NODE_FALLBACK_BUN_WHICH("node") + + expect(resolvedPath).not.toBeNull() + expect(resolvedPath?.toLowerCase()).toMatch(/node(?:\.exe)?$/) + }) + + test("#when a non-existent command is resolved #then returns null", () => { + const resolvedPath = NODE_FALLBACK_BUN_WHICH("this-does-not-exist-abc123xyz") + + expect(resolvedPath).toBeNull() + }) + + test("#when an empty string is resolved #then returns null", () => { + const resolvedPath = NODE_FALLBACK_BUN_WHICH("") + + expect(resolvedPath).toBeNull() + }) + + test("#when path-traversal command names are resolved #then returns null", () => { + for (const commandName of PATH_TRAVERSAL_COMMAND_NAMES) { + expect(NODE_FALLBACK_BUN_WHICH(commandName)).toBeNull() + } + }) + + test("#when a null-byte command name is resolved #then returns null", () => { + expect(NODE_FALLBACK_BUN_WHICH(NULL_BYTE_COMMAND_NAME)).toBeNull() + }) +}) diff --git a/src/shared/bun-which-shim.ts b/src/shared/bun-which-shim.ts new file mode 100644 index 000000000..47d974918 --- /dev/null +++ b/src/shared/bun-which-shim.ts @@ -0,0 +1,58 @@ +import { accessSync, constants } from "node:fs" +import { delimiter, join } from "node:path" + +type BunWhichRuntime = { which(commandName: string): string | null } +const runtime = globalThis as typeof globalThis & { Bun?: BunWhichRuntime } +const IS_BUN = typeof runtime.Bun !== "undefined" + +function isUnsafeCommandName(commandName: string): boolean { + if (commandName.includes("/") || commandName.includes("\\")) return true + if (commandName === "." || commandName === ".." || commandName.includes("..")) return true + if (/^[a-zA-Z]:/.test(commandName)) return true + if (commandName.includes("\0")) return true + + return false +} + +function isExecutable(filePath: string): boolean { + try { + accessSync(filePath, constants.X_OK) + return true + } catch { + return false + } +} + +function resolvePathValue(): string | undefined { + if (process.platform === "win32") return process.env.Path ?? process.env.PATH + + return process.env.PATH +} + +function getWindowsCandidates(commandName: string): string[] { + if (process.platform !== "win32") return [commandName] + + return [commandName, `${commandName}.exe`, `${commandName}.cmd`, `${commandName}.bat`, `${commandName}.com`] +} + +export function bunWhich(commandName: string): string | null { + if (!commandName) return null + if (isUnsafeCommandName(commandName)) return null + if (IS_BUN) return runtime.Bun?.which(commandName) ?? null + + const pathValue = resolvePathValue() + if (!pathValue) return null + + const pathEntries = pathValue.split(delimiter).filter((pathEntry) => pathEntry.length > 0) + if (pathEntries.length === 0) return null + + const candidateNames = getWindowsCandidates(commandName) + for (const pathEntry of pathEntries) { + for (const candidateName of candidateNames) { + const candidatePath = join(pathEntry, candidateName) + if (isExecutable(candidatePath)) return candidatePath + } + } + + return null +} From 0aafe20a85412e087ddc735aaa123d4a500b939a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 3/9] refactor: route raw Bun.file/write/hash/which/spawn through runtime shims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates 19 unguarded `Bun.*` runtime call sites in the plugin bundle that crashed with `ReferenceError: Bun is not defined` under Electron. Per-tool-call hot paths (executed on every Read/Edit): - src/tools/hashline-edit/hash-computation.ts: Bun.hash.xxHash32 → bunHashXxh32 - src/tools/hashline-edit/hashline-edit-executor.ts: 8 sites via bunFile/bunWrite - src/hooks/hashline-read-enhancer/hook.ts: Bun.file → bunFile - src/hooks/hashline-edit-diff-enhancer/hook.ts: 2 sites via bunFile Plugin-load paths: - src/hooks/claude-code-hooks/config.ts and config-loader.ts: Bun.file → bunFile - src/features/claude-code-mcp-loader/loader.ts: Bun.file → bunFile - src/features/claude-code-plugin-loader/mcp-server-loader.ts: Bun.file → bunFile - src/features/team-mode/deps.ts: Bun.spawn → spawn shim - src/hooks/session-notification-utils.ts: Bun.which → bunWhich, also drops the bare `declare const Bun` ambient declaration - src/shared/binary-downloader.ts: Bun.write → bunWrite Pure mechanical API swaps. No control-flow or signature changes. --- src/features/claude-code-mcp-loader/loader.ts | 3 ++- .../mcp-server-loader.ts | 3 ++- src/features/team-mode/deps.ts | 3 ++- src/hooks/claude-code-hooks/config-loader.ts | 3 ++- src/hooks/claude-code-hooks/config.ts | 3 ++- src/hooks/hashline-edit-diff-enhancer/hook.ts | 5 +++-- src/hooks/hashline-read-enhancer/hook.ts | 3 ++- src/hooks/session-notification-utils.ts | 7 ++----- src/shared/binary-downloader.ts | 3 ++- src/tools/hashline-edit/hash-computation.ts | 3 ++- .../hashline-edit/hashline-edit-executor.ts | 17 +++++++++-------- 11 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/features/claude-code-mcp-loader/loader.ts b/src/features/claude-code-mcp-loader/loader.ts index 7be6a9ac7..54d5cb3e5 100644 --- a/src/features/claude-code-mcp-loader/loader.ts +++ b/src/features/claude-code-mcp-loader/loader.ts @@ -11,6 +11,7 @@ import type { import { transformMcpServer } from "./transformer" import { log } from "../../shared/logger" import { shouldLoadMcpServer } from "./scope-filter" +import { bunFile } from "../../shared/bun-file-shim" interface McpConfigPath { path: string @@ -37,7 +38,7 @@ async function loadMcpConfigFile( } try { - const content = await Bun.file(filePath).text() + const content = await bunFile(filePath).text() return JSON.parse(content) as ClaudeCodeMcpConfig } catch (error) { log(`Failed to load MCP config from ${filePath}`, error) diff --git a/src/features/claude-code-plugin-loader/mcp-server-loader.ts b/src/features/claude-code-plugin-loader/mcp-server-loader.ts index b0f0f8b8f..3804bdb7d 100644 --- a/src/features/claude-code-plugin-loader/mcp-server-loader.ts +++ b/src/features/claude-code-plugin-loader/mcp-server-loader.ts @@ -7,6 +7,7 @@ import type { ClaudeCodeMcpConfig } from "../claude-code-mcp-loader/types" import { log } from "../../shared/logger" import type { LoadedPlugin } from "./types" import { resolvePluginPaths } from "./plugin-path-resolver" +import { bunFile } from "../../shared/bun-file-shim" export async function loadPluginMcpServers( plugins: LoadedPlugin[], @@ -18,7 +19,7 @@ export async function loadPluginMcpServers( if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue try { - const content = await Bun.file(plugin.mcpPath).text() + const content = await bunFile(plugin.mcpPath).text() let config = JSON.parse(content) as ClaudeCodeMcpConfig config = resolvePluginPaths(config, plugin.installPath) diff --git a/src/features/team-mode/deps.ts b/src/features/team-mode/deps.ts index 25db5f966..ecb06c238 100644 --- a/src/features/team-mode/deps.ts +++ b/src/features/team-mode/deps.ts @@ -1,4 +1,5 @@ import type { TeamModeConfig } from "../../config/schema/team-mode" +import { spawn } from "../../shared/bun-spawn-shim" export interface TeamModeDependencyReport { tmuxAvailable: boolean @@ -20,7 +21,7 @@ export async function checkTeamModeDependencies( async function probeBinary(cmd: string, args: string[]): Promise { try { - const proc = Bun.spawn({ cmd: [cmd, ...args], stdout: "pipe", stderr: "pipe" }) + const proc = spawn({ cmd: [cmd, ...args], stdout: "pipe", stderr: "pipe" }) const code = await proc.exited return code === 0 } catch { diff --git a/src/hooks/claude-code-hooks/config-loader.ts b/src/hooks/claude-code-hooks/config-loader.ts index a01abb5eb..ea494ffdb 100644 --- a/src/hooks/claude-code-hooks/config-loader.ts +++ b/src/hooks/claude-code-hooks/config-loader.ts @@ -3,6 +3,7 @@ import { join } from "path" import type { ClaudeHookEvent } from "./types" import { log } from "../../shared/logger" import { getOpenCodeConfigDir } from "../../shared" +import { bunFile } from "../../shared/bun-file-shim" const CONFIG_CACHE_TTL_MS = 30_000 @@ -61,7 +62,7 @@ async function loadConfigFromPath(path: string): Promise): string | undefined { async function captureOldContent(filePath: string): Promise { try { - const file = Bun.file(filePath) + const file = bunFile(filePath) if (await file.exists()) { return await file.text() } @@ -79,7 +80,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan let newContent: string try { - newContent = await Bun.file(filePath).text() + newContent = await bunFile(filePath).text() } catch { log("[hashline-edit-diff-enhancer] failed to read new content", { filePath }) return diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 093312d4a..ded243d91 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -1,4 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" +import { bunFile } from "../../shared/bun-file-shim" import { computeLineHash } from "../../tools/hashline-edit/hash-computation" const WRITE_SUCCESS_MARKER = "File written successfully." @@ -178,7 +179,7 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk return } - const file = Bun.file(filePath) + const file = bunFile(filePath) if (!(await file.exists())) { return } diff --git a/src/hooks/session-notification-utils.ts b/src/hooks/session-notification-utils.ts index b3eb8e32d..0c690dca3 100644 --- a/src/hooks/session-notification-utils.ts +++ b/src/hooks/session-notification-utils.ts @@ -1,14 +1,11 @@ import { log } from "../shared/logger" - -declare const Bun: { - which(commandName: string): string | null -} +import { bunWhich } from "../shared/bun-which-shim" type Platform = "darwin" | "linux" | "win32" | "unsupported" async function findCommand(commandName: string): Promise { try { - return Bun.which(commandName) + return bunWhich(commandName) } catch (error) { log("[session-notification] failed to resolve command path", { commandName, diff --git a/src/shared/binary-downloader.ts b/src/shared/binary-downloader.ts index 16a8ff60b..a44206c2e 100644 --- a/src/shared/binary-downloader.ts +++ b/src/shared/binary-downloader.ts @@ -1,6 +1,7 @@ import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs"; import * as path from "node:path"; import { spawn } from "./bun-spawn-shim"; +import { bunWrite } from "./bun-file-shim"; import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator"; import { extractZip } from "./zip-extractor"; @@ -26,7 +27,7 @@ export async function downloadArchive(downloadUrl: string, archivePath: string): } const arrayBuffer = await response.arrayBuffer(); - await Bun.write(archivePath, arrayBuffer); + await bunWrite(archivePath, arrayBuffer); } export async function extractTarGz( diff --git a/src/tools/hashline-edit/hash-computation.ts b/src/tools/hashline-edit/hash-computation.ts index a6bf8da78..e5c31b67a 100644 --- a/src/tools/hashline-edit/hash-computation.ts +++ b/src/tools/hashline-edit/hash-computation.ts @@ -1,12 +1,13 @@ import { HASHLINE_DICT } from "./constants" import { createHashlineChunkFormatter } from "./hashline-chunk-formatter" +import { bunHashXxh32 } from "../../shared/bun-hash-shim" const RE_SIGNIFICANT = /[\p{L}\p{N}]/u function computeNormalizedLineHash(lineNumber: number, normalizedContent: string): string { const stripped = normalizedContent const seed = RE_SIGNIFICANT.test(stripped) ? 0 : lineNumber - const hash = Bun.hash.xxHash32(stripped, seed) + const hash = bunHashXxh32(stripped, seed) const index = hash % 256 return HASHLINE_DICT[index] } diff --git a/src/tools/hashline-edit/hashline-edit-executor.ts b/src/tools/hashline-edit/hashline-edit-executor.ts index 54509ab6c..7b450b880 100644 --- a/src/tools/hashline-edit/hashline-edit-executor.ts +++ b/src/tools/hashline-edit/hashline-edit-executor.ts @@ -1,5 +1,6 @@ import type { ToolContext } from "@opencode-ai/plugin/tool" import { publishToolMetadata } from "../../features/tool-metadata-store" +import { bunFile, bunWrite } from "../../shared/bun-file-shim" import { applyHashlineEditsWithReport } from "./edit-operations" import { countLineDiffs, generateUnifiedDiff } from "./diff-utils" import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization" @@ -94,7 +95,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T const edits = deleteMode ? [] : normalizeHashlineEdits(args.edits) - const file = Bun.file(filePath) + const file = bunFile(filePath) const exists = await file.exists() if (!exists && !deleteMode && !canCreateFromMissingFile(edits)) { return `Error: File not found: ${filePath}` @@ -102,7 +103,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T if (deleteMode) { if (!exists) return `Error: File not found: ${filePath}` - await Bun.file(filePath).delete() + await bunFile(filePath).delete() return `Successfully deleted ${filePath}` } @@ -122,11 +123,11 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T const writeContent = restoreFileText(canonicalNewContent, oldEnvelope) - await Bun.write(filePath, writeContent) + await bunWrite(filePath, writeContent) if (pluginCtx?.client) { await runFormattersForFile(pluginCtx.client as FormatterClient, context.directory, filePath) - const formattedContent = Buffer.from(await Bun.file(filePath).arrayBuffer()).toString("utf8") + const formattedContent = Buffer.from(await bunFile(filePath).arrayBuffer()).toString("utf8") if (formattedContent !== writeContent) { const formattedEnvelope = canonicalizeFileText(formattedContent) const formattedMeta = buildSuccessMeta( @@ -138,8 +139,8 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T ) await publishToolMetadata(metadataContext, formattedMeta) if (rename && rename !== filePath) { - await Bun.write(rename, formattedContent) - await Bun.file(filePath).delete() + await bunWrite(rename, formattedContent) + await bunFile(filePath).delete() return `Moved ${filePath} to ${rename}` } return `Updated ${filePath}` @@ -147,8 +148,8 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T } if (rename && rename !== filePath) { - await Bun.write(rename, writeContent) - await Bun.file(filePath).delete() + await bunWrite(rename, writeContent) + await bunFile(filePath).delete() } const effectivePath = rename && rename !== filePath ? rename : filePath From 2386cbd9b916eae476b54c9a292bb404bbe8aa81 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 4/9] refactor(port-utils): drop Bun.serve in favor of node:net probe isPortAvailable() previously bound a one-shot Bun.serve and stopped it. That call was reachable from the plugin bundle through src/shared/index.ts barrel re-export and crashed on Electron. Switch to node:net.createServer().listen(port, host), which Bun fully implements as well. Adds a 2s safety timeout and removes both "error" and "listening" handlers on resolution to prevent listener leaks. Behavior is bit-equivalent: returns true iff a server can bind to (host, port) right now. Test file is fully rewritten away from stale Bun.serve mocking. New tests exercise: free-port detection via port 0, EADDRINUSE handling via a real net.createServer blocker, findAvailablePort range exhaustion, getAvailableServerPort auto-selection, 127.0.0.1 default hostname binding, and probe-server resource cleanup. --- src/shared/port-utils.test.ts | 650 ++++++++++++++++++++-------------- src/shared/port-utils.ts | 55 ++- 2 files changed, 421 insertions(+), 284 deletions(-) diff --git a/src/shared/port-utils.test.ts b/src/shared/port-utils.test.ts index 3b1be1cf9..46fbc65b4 100644 --- a/src/shared/port-utils.test.ts +++ b/src/shared/port-utils.test.ts @@ -1,291 +1,393 @@ -import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" -import { - isPortAvailable, - findAvailablePort, - getAvailableServerPort, - DEFAULT_SERVER_PORT, -} from "./port-utils" +import { createServer, Server } from "node:net" +import type { AddressInfo } from "node:net" +import { networkInterfaces } from "node:os" -const HOSTNAME = "127.0.0.1" -const REAL_PORT_SEARCH_WINDOW = 200 +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" -function supportsRealSocketBinding(): boolean { - try { - const server = Bun.serve({ - port: 0, - hostname: HOSTNAME, - fetch: () => new Response("probe"), +import { DEFAULT_SERVER_PORT, findAvailablePort, getAvailableServerPort, isPortAvailable } from "./port-utils" + +const DEFAULT_HOSTNAME = "127.0.0.1" +const MAX_PORT_ATTEMPTS = 20 +const EXHAUSTED_PORT_COUNT = MAX_PORT_ATTEMPTS + 1 +const CONTIGUOUS_SEARCH_WINDOW = 256 +const CONTIGUOUS_SEARCH_SEEDS = 8 + +const trackedServers = new Set() + +type TimeoutProbeResult = { + closeCallCount: number + isAvailable: boolean + server: Server | undefined +} + +function getRequiredPropertyDescriptor(target: object, propertyName: string): PropertyDescriptor { + const descriptor = Object.getOwnPropertyDescriptor(target, propertyName) + if (!descriptor) { + throw new Error(`Expected ${propertyName} property descriptor`) + } + + return descriptor +} + +function isTcpAddress(address: ReturnType): address is AddressInfo { + return typeof address === "object" && address !== null && "port" in address +} + +function getServerPort(server: Server): number { + const address = server.address() + if (!isTcpAddress(address)) { + throw new Error("Expected TCP server address") + } + + return address.port +} + +function getAlternateIpv4Hostname(): string | undefined { + for (const addresses of Object.values(networkInterfaces())) { + if (!addresses) continue + + for (const address of addresses) { + if (address.family === "IPv4" && !address.internal && address.address !== DEFAULT_HOSTNAME) { + return address.address + } + } + } + + return undefined +} + +function startTrackedServer(port: number, hostname: string = DEFAULT_HOSTNAME): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + + const removeListeners = (): void => { + server.removeListener("error", handleError) + server.removeListener("listening", handleListening) + } + + const handleError = (error: Error): void => { + removeListeners() + trackedServers.delete(server) + reject(error) + } + + const handleListening = (): void => { + removeListeners() + trackedServers.add(server) + resolve(server) + } + + server.once("error", handleError) + server.once("listening", handleListening) + + try { + server.listen(port, hostname) + } catch (error) { + removeListeners() + trackedServers.delete(server) + reject(error) + } + }) +} + +function closeTrackedServer(server: Server): Promise { + trackedServers.delete(server) + + if (!server.listening) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error) + return + } + + resolve() }) - server.stop(true) + }) +} + +async function closeAllTrackedServers(): Promise { + await Promise.all(Array.from(trackedServers).map((server) => closeTrackedServer(server))) +} + +async function getReleasedPort(hostname: string = DEFAULT_HOSTNAME): Promise { + const server = await startTrackedServer(0, hostname) + const port = getServerPort(server) + await closeTrackedServer(server) + + return port +} + +async function canBindContiguousPorts( + startPort: number, + portCount: number, + hostname: string = DEFAULT_HOSTNAME +): Promise { + const servers: Server[] = [] + + try { + for (let offset = 0; offset < portCount; offset++) { + servers.push(await startTrackedServer(startPort + offset, hostname)) + } + return true } catch { return false + } finally { + await Promise.all(servers.map((server) => closeTrackedServer(server))) } } -const canBindRealSockets = supportsRealSocketBinding() +async function findContiguousAvailableStart( + portCount: number, + hostname: string = DEFAULT_HOSTNAME +): Promise { + for (let seedAttempt = 0; seedAttempt < CONTIGUOUS_SEARCH_SEEDS; seedAttempt++) { + const seedPort = await getReleasedPort(hostname) + const maxStartPort = Math.min(65_535 - portCount + 1, seedPort + CONTIGUOUS_SEARCH_WINDOW) -describe("port-utils", () => { - if (canBindRealSockets) { - function startRealBlocker(port: number = 0) { - return Bun.serve({ - port, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - } - - async function findContiguousAvailableStart(length: number): Promise { - const probe = startRealBlocker() - const seedPort = probe.port - probe.stop(true) - - for (let candidate = seedPort; candidate < seedPort + REAL_PORT_SEARCH_WINDOW; candidate++) { - const checks = await Promise.all( - Array.from({ length }, async (_, offset) => isPortAvailable(candidate + offset, HOSTNAME)) - ) - if (checks.every(Boolean)) { - return candidate - } + for (let candidatePort = seedPort; candidatePort <= maxStartPort; candidatePort++) { + if (await canBindContiguousPorts(candidatePort, portCount, hostname)) { + return candidatePort } - - throw new Error(`Could not find ${length} contiguous available ports`) } - - describe("with real sockets", () => { - describe("isPortAvailable", () => { - it("#given unused port #when checking availability #then returns true", async () => { - const blocker = startRealBlocker() - const port = blocker.port - blocker.stop(true) - - const result = await isPortAvailable(port) - expect(result).toBe(true) - }) - - it("#given port in use #when checking availability #then returns false", async () => { - const blocker = startRealBlocker() - const port = blocker.port - - try { - const result = await isPortAvailable(port) - expect(result).toBe(false) - } finally { - blocker.stop(true) - } - }) - }) - - describe("findAvailablePort", () => { - it("#given start port available #when finding port #then returns start port", async () => { - const startPort = await findContiguousAvailableStart(1) - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort) - }) - - it("#given start port blocked #when finding port #then returns next available", async () => { - const startPort = await findContiguousAvailableStart(2) - const blocker = startRealBlocker(startPort) - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 1) - } finally { - blocker.stop(true) - } - }) - - it("#given multiple ports blocked #when finding port #then skips all blocked", async () => { - const startPort = await findContiguousAvailableStart(4) - const blockers = [ - startRealBlocker(startPort), - startRealBlocker(startPort + 1), - startRealBlocker(startPort + 2), - ] - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 3) - } finally { - blockers.forEach((blocker) => blocker.stop(true)) - } - }) - }) - - describe("getAvailableServerPort", () => { - it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => { - const preferredPort = await findContiguousAvailableStart(1) - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort) - expect(result.wasAutoSelected).toBe(false) - }) - - it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => { - const preferredPort = await findContiguousAvailableStart(2) - const blocker = startRealBlocker(preferredPort) - - try { - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort + 1) - expect(result.wasAutoSelected).toBe(true) - } finally { - blocker.stop(true) - } - }) - }) - }) - } else { - const blockedSockets = new Set() - let serveSpy: ReturnType - - function getSocketKey(port: number, hostname: string): string { - return `${hostname}:${port}` - } - - beforeEach(() => { - blockedSockets.clear() - serveSpy = spyOn(Bun, "serve").mockImplementation(({ port, hostname }) => { - if (typeof port !== "number") { - throw new Error("Test expected numeric port") - } - const resolvedHostname = typeof hostname === "string" ? hostname : HOSTNAME - const socketKey = getSocketKey(port, resolvedHostname) - - if (blockedSockets.has(socketKey)) { - const error = new Error(`Failed to start server. Is port ${port} in use?`) as Error & { - code?: string - syscall?: string - errno?: number - address?: string - port?: number - } - error.code = "EADDRINUSE" - error.syscall = "listen" - error.errno = 0 - error.address = resolvedHostname - error.port = port - throw error - } - - blockedSockets.add(socketKey) - return { - stop: (_force?: boolean) => { - blockedSockets.delete(socketKey) - }, - } as { stop: (force?: boolean) => void } - }) - }) - - afterEach(() => { - expect(blockedSockets.size).toBe(0) - serveSpy.mockRestore() - blockedSockets.clear() - }) - - describe("with mocked sockets fallback", () => { - describe("isPortAvailable", () => { - it("#given unused port #when checking availability #then returns true", async () => { - const port = 59999 - - const result = await isPortAvailable(port) - expect(result).toBe(true) - expect(blockedSockets.size).toBe(0) - }) - - it("#given port in use #when checking availability #then returns false", async () => { - const port = 59998 - const blocker = Bun.serve({ - port, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - - try { - const result = await isPortAvailable(port) - expect(result).toBe(false) - } finally { - blocker.stop(true) - } - }) - - it("#given custom hostname #when checking availability #then passes hostname through to Bun.serve", async () => { - const hostname = "192.0.2.10" - await isPortAvailable(59995, hostname) - - expect(serveSpy.mock.calls[0]?.[0]?.hostname).toBe(hostname) - }) - }) - - describe("findAvailablePort", () => { - it("#given start port available #when finding port #then returns start port", async () => { - const startPort = 59997 - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort) - }) - - it("#given start port blocked #when finding port #then returns next available", async () => { - const startPort = 59996 - const blocker = Bun.serve({ - port: startPort, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 1) - } finally { - blocker.stop(true) - } - }) - - it("#given multiple ports blocked #when finding port #then skips all blocked", async () => { - const startPort = 59993 - const blockers = [ - Bun.serve({ port: startPort, hostname: HOSTNAME, fetch: () => new Response() }), - Bun.serve({ port: startPort + 1, hostname: HOSTNAME, fetch: () => new Response() }), - Bun.serve({ port: startPort + 2, hostname: HOSTNAME, fetch: () => new Response() }), - ] - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 3) - } finally { - blockers.forEach((blocker) => blocker.stop(true)) - } - }) - }) - - describe("getAvailableServerPort", () => { - it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => { - const preferredPort = 59990 - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort) - expect(result.wasAutoSelected).toBe(false) - }) - - it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => { - const preferredPort = 59989 - const blocker = Bun.serve({ - port: preferredPort, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - - try { - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort + 1) - expect(result.wasAutoSelected).toBe(true) - } finally { - blocker.stop(true) - } - }) - }) - }) } - describe("DEFAULT_SERVER_PORT", () => { - it("#given constant #when accessed #then returns 4096", () => { + throw new Error(`Could not find ${portCount} contiguous available ports`) +} + +async function startConsecutiveBlockers( + startPort: number, + portCount: number, + hostname: string = DEFAULT_HOSTNAME +): Promise { + const servers: Server[] = [] + + try { + for (let offset = 0; offset < portCount; offset++) { + servers.push(await startTrackedServer(startPort + offset, hostname)) + } + + return servers + } catch (error) { + await Promise.all(servers.map((server) => closeTrackedServer(server))) + throw error + } +} + +async function captureDefaultListenHostname(port: number): Promise { + const listenDescriptor = getRequiredPropertyDescriptor(Server.prototype, "listen") + const closeDescriptor = getRequiredPropertyDescriptor(Server.prototype, "close") + let capturedHostname: string | undefined + + Object.defineProperty(Server.prototype, "listen", { + configurable: true, + value: function listenAndCaptureHostname(this: Server, requestedPort: number, hostname?: string): Server { + if (requestedPort === port) { + capturedHostname = hostname + } + queueMicrotask(() => this.emit("listening")) + return this + }, + }) + Object.defineProperty(Server.prototype, "close", { + configurable: true, + value: function closeCapturedServer(this: Server, callback?: (error?: Error) => void): Server { + queueMicrotask(() => callback?.()) + return this + }, + }) + + try { + await isPortAvailable(port) + return capturedHostname + } finally { + Object.defineProperty(Server.prototype, "listen", listenDescriptor) + Object.defineProperty(Server.prototype, "close", closeDescriptor) + } +} + +async function runTimedOutAvailabilityProbe(port: number): Promise { + const setTimeoutDescriptor = getRequiredPropertyDescriptor(globalThis, "setTimeout") + const listenDescriptor = getRequiredPropertyDescriptor(Server.prototype, "listen") + const closeDescriptor = getRequiredPropertyDescriptor(Server.prototype, "close") + const originalSetTimeout = globalThis.setTimeout + let timedOutServer: Server | undefined + let closeCallCount = 0 + + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: (callback: () => void): ReturnType => originalSetTimeout(callback, 0), + }) + Object.defineProperty(Server.prototype, "listen", { + configurable: true, + value: function listenWithoutEmitting(this: Server): Server { + timedOutServer = this + return this + }, + }) + Object.defineProperty(Server.prototype, "close", { + configurable: true, + value: function closeTimedOutServer(this: Server, callback?: (error?: Error) => void): Server { + closeCallCount++ + queueMicrotask(() => callback?.()) + return this + }, + }) + + try { + const isAvailable = await isPortAvailable(port) + return { closeCallCount, isAvailable, server: timedOutServer } + } finally { + Object.defineProperty(globalThis, "setTimeout", setTimeoutDescriptor) + Object.defineProperty(Server.prototype, "listen", listenDescriptor) + Object.defineProperty(Server.prototype, "close", closeDescriptor) + } +} + +describe("port-utils", () => { + beforeAll(() => { + trackedServers.clear() + }) + + afterEach(async () => { + await closeAllTrackedServers() + }) + + afterAll(async () => { + await closeAllTrackedServers() + }) + + describe("#given isPortAvailable", () => { + test("#when a released port is checked #then returns true", async () => { + const port = await getReleasedPort() + + const result = await isPortAvailable(port) + + expect(result).toBe(true) + }) + + test("#when an already bound port is checked #then returns false", async () => { + const blocker = await startTrackedServer(0) + const port = getServerPort(blocker) + + const result = await isPortAvailable(port) + + expect(result).toBe(false) + }) + + test("#when a timed out probe is cleaned up #then no listeners or server remain active", async () => { + const port = await getReleasedPort() + + const result = await runTimedOutAvailabilityProbe(port) + + expect(result.isAvailable).toBe(false) + expect(result.closeCallCount).toBe(1) + expect(result.server).toBeDefined() + if (!result.server) { + throw new Error("Expected timed out server") + } + expect(result.server.listening).toBe(false) + expect(result.server.listenerCount("error")).toBe(0) + expect(result.server.listenerCount("listening")).toBe(0) + }) + + test("#when a successful probe finishes #then the port can be rebound immediately", async () => { + const port = await getReleasedPort() + + const result = await isPortAvailable(port) + const server = await startTrackedServer(port) + + expect(result).toBe(true) + expect(getServerPort(server)).toBe(port) + }) + + test("#when hostname is omitted #then 127.0.0.1 is the default target", async () => { + const blocker = await startTrackedServer(0, DEFAULT_HOSTNAME) + const port = getServerPort(blocker) + + const result = await isPortAvailable(port) + + expect(result).toBe(false) + }) + + test("#when another interface owns the port #then default probing does not bind all interfaces", async () => { + const alternateHostname = getAlternateIpv4Hostname() + + if (!alternateHostname) { + const port = await getReleasedPort() + const capturedHostname = await captureDefaultListenHostname(port) + expect(capturedHostname).toBe(DEFAULT_HOSTNAME) + return + } + + const blocker = await startTrackedServer(0, alternateHostname) + const port = getServerPort(blocker) + + expect(await isPortAvailable(port)).toBe(true) + expect(await isPortAvailable(port, alternateHostname)).toBe(false) + }) + }) + + describe("#given findAvailablePort", () => { + test("#when the start port is available #then returns the start port", async () => { + const startPort = await findContiguousAvailableStart(1) + + const result = await findAvailablePort(startPort) + + expect(result).toBe(startPort) + }) + + test("#when the first three ports are blocked #then returns the next free port", async () => { + const startPort = await findContiguousAvailableStart(4) + await startConsecutiveBlockers(startPort, 3) + + const result = await findAvailablePort(startPort) + + expect(result).toBe(startPort + 3) + }) + + test("#when every attempted port is blocked #then throws", async () => { + const startPort = await findContiguousAvailableStart(EXHAUSTED_PORT_COUNT) + await startConsecutiveBlockers(startPort, EXHAUSTED_PORT_COUNT) + + let errorMessage: string | undefined + try { + await findAvailablePort(startPort) + } catch (error) { + if (!(error instanceof Error)) { + throw error + } + errorMessage = error.message + } + + expect(errorMessage).toBe(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`) + }) + }) + + describe("#given getAvailableServerPort", () => { + test("#when the preferred port is free #then returns the preferred port without auto-selection", async () => { + const preferredPort = await findContiguousAvailableStart(1) + + const result = await getAvailableServerPort(preferredPort) + + expect(result).toEqual({ port: preferredPort, wasAutoSelected: false }) + }) + + test("#when the preferred port is blocked #then returns the next port with auto-selection", async () => { + const preferredPort = await findContiguousAvailableStart(2) + await startTrackedServer(preferredPort) + + const result = await getAvailableServerPort(preferredPort) + expect(result).toEqual({ port: preferredPort + 1, wasAutoSelected: true }) + }) + }) + + describe("#given DEFAULT_SERVER_PORT", () => { + test("#when accessed #then returns 4096", () => { expect(DEFAULT_SERVER_PORT).toBe(4096) }) }) diff --git a/src/shared/port-utils.ts b/src/shared/port-utils.ts index 978a2658c..94a908a97 100644 --- a/src/shared/port-utils.ts +++ b/src/shared/port-utils.ts @@ -1,18 +1,53 @@ +import { createServer } from "node:net" + const DEFAULT_SERVER_PORT = 4096 const MAX_PORT_ATTEMPTS = 20 +const PORT_CHECK_TIMEOUT_MS = 2000 export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise { - try { - const server = Bun.serve({ - port, - hostname, - fetch: () => new Response(), + return new Promise((resolve) => { + const server = createServer() + let timeoutId: ReturnType | undefined + let resolved = false + + const finish = (isAvailable: boolean): void => { + if (resolved) { + return + } + resolved = true + if (timeoutId) { + clearTimeout(timeoutId) + } + server.removeAllListeners("error") + server.removeAllListeners("listening") + resolve(isAvailable) + } + + const closeThenFinish = (isAvailable: boolean): void => { + try { + server.close(() => finish(isAvailable)) + } catch { + finish(isAvailable) + } + } + + timeoutId = setTimeout(() => { + closeThenFinish(false) + }, PORT_CHECK_TIMEOUT_MS) + + server.once("error", () => { + finish(false) }) - server.stop(true) - return true - } catch { - return false - } + server.once("listening", () => { + closeThenFinish(true) + }) + + try { + server.listen(port, hostname) + } catch { + finish(false) + } + }) } export async function findAvailablePort( From 22c7e4eb8ee5742c825e169dffffb85e7170e3e8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 5/9] refactor(mcp-oauth): replace Bun.serve with node:http in callback-server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth callback server was using Bun.serve, which would crash if mcp-oauth code paths ever entered the plugin bundle. Switch to node:http.createServer with the same WHATWG behavior: - Binds to 127.0.0.1, preserves 200/400/404 status codes - Translates fetch(Request)→Response to (req, res) callback style - Clears OAuth timeout on success/error/missing-param paths - Replaces server.stop(true) with server.close() for shutdown Functional behavior and response bodies unchanged. --- src/features/mcp-oauth/callback-server.ts | 92 ++++++++++++++--------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/src/features/mcp-oauth/callback-server.ts b/src/features/mcp-oauth/callback-server.ts index 48dcb1729..64ff35a19 100644 --- a/src/features/mcp-oauth/callback-server.ts +++ b/src/features/mcp-oauth/callback-server.ts @@ -1,3 +1,5 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http" + import { findAvailablePort as findAvailablePortShared } from "../../shared/port-utils" const DEFAULT_PORT = 19877 @@ -51,56 +53,74 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro const timeoutId = setTimeout(() => { rejectCallback?.(new Error("OAuth callback timed out after 5 minutes")) - server.stop(true) + server.close() }, TIMEOUT_MS) - const server = Bun.serve({ - port: requestedPort, - hostname: "127.0.0.1", - fetch(request: Request): Response { - const url = new URL(request.url) + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + const host = request.headers.host ?? "127.0.0.1" + const url = new URL(request.url ?? "/", `http://${host}`) - if (url.pathname !== "/oauth/callback") { - return new Response("Not Found", { status: 404 }) - } + if (url.pathname !== "/oauth/callback") { + response.statusCode = 404 + response.end("Not Found") + return + } - const oauthError = url.searchParams.get("error") - if (oauthError) { - const description = url.searchParams.get("error_description") ?? oauthError - clearTimeout(timeoutId) - rejectCallback?.(new Error(`OAuth authorization failed: ${description}`)) - setTimeout(() => server.stop(true), 100) - return new Response(`Authorization failed: ${description}`, { status: 400 }) - } - - const code = url.searchParams.get("code") - const state = url.searchParams.get("state") - - if (!code || !state) { - clearTimeout(timeoutId) - rejectCallback?.(new Error("OAuth callback missing code or state parameter")) - setTimeout(() => server.stop(true), 100) - return new Response("Missing code or state parameter", { status: 400 }) - } - - resolveCallback?.({ code, state }) + const oauthError = url.searchParams.get("error") + if (oauthError) { + const description = url.searchParams.get("error_description") ?? oauthError clearTimeout(timeoutId) + rejectCallback?.(new Error(`OAuth authorization failed: ${description}`)) + response.statusCode = 400 + response.end(`Authorization failed: ${description}`) + setTimeout(() => server.close(), 100) + return + } - setTimeout(() => server.stop(true), 100) + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") - return new Response(SUCCESS_HTML, { - headers: { "content-type": "text/html; charset=utf-8" }, - }) - }, + if (!code || !state) { + clearTimeout(timeoutId) + rejectCallback?.(new Error("OAuth callback missing code or state parameter")) + response.statusCode = 400 + response.end("Missing code or state parameter") + setTimeout(() => server.close(), 100) + return + } + + resolveCallback?.({ code, state }) + clearTimeout(timeoutId) + + response.statusCode = 200 + response.setHeader("content-type", "text/html; charset=utf-8") + response.end(SUCCESS_HTML) + setTimeout(() => server.close(), 100) }) - const activePort = server.port ?? requestedPort + + await new Promise((resolve, reject) => { + const handleError = (error: Error): void => { + clearTimeout(timeoutId) + reject(error) + } + + server.once("error", handleError) + server.once("listening", () => { + server.off("error", handleError) + resolve() + }) + server.listen(requestedPort, "127.0.0.1") + }) + + const address = server.address() + const activePort = typeof address === "object" && address !== null ? address.port : requestedPort return { port: activePort, waitForCallback: () => callbackPromise, close: () => { clearTimeout(timeoutId) - server.stop(true) + server.close() }, } } From 11529394aa969d629a605e64e27aac942aafe850 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 6/9] refactor(cli): use Response(stream).text() instead of Bun.readableStreamToText bun-install.ts streamToText() was reachable from the plugin bundle via the cli/config-manager barrel re-export. Replace with the WHATWG standard `new Response(stream).text()` pattern which works identically in Bun and Node and avoids the last raw Bun.* runtime call in dist/index.js. --- src/cli/config-manager/bun-install.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts index b744902f1..a2851d5fd 100644 --- a/src/cli/config-manager/bun-install.ts +++ b/src/cli/config-manager/bun-install.ts @@ -26,10 +26,6 @@ declare function clearTimeout(timeout: number): void type ProcessOutputStream = ReturnType["stdout"] -declare const Bun: { - readableStreamToText(stream: NonNullable): Promise -} - export interface BunInstallResult { success: boolean timedOut?: boolean @@ -50,7 +46,7 @@ function readProcessOutput(stream: ProcessOutputStream): Promise { return Promise.resolve("") } - return Bun.readableStreamToText(stream) + return new Response(stream).text() } function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: BunInstallOutput): void { From db3256baf619262e83617c04b3dacb39b977b0a3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 7/9] test: harden dist-bundle regression guard + Node smoke test Existing test only checked `globalThis.Bun` top-level destructures and `__require` calls. Add two new test cases: 1. Raw Bun runtime API scanner: scans dist/index.js for any `Bun.(` or `Bun..` call outside shim-safe patterns (runtime.Bun, globalThis.Bun, typeof Bun, and the "Bun is not defined" error-message string). Uses a negative lookbehind so shim indirection (`runtime.Bun.foo`) passes. 2. Node smoke test: imports dist/index.js under `node --input-type=module` and asserts stderr contains no `ReferenceError` and no `Bun is not defined`. The existing case 3 only checked exit code, which masked lazy-evaluation crashes that fire after import. Reading exports forces lazy module-level evaluation paths to run. --- src/shared/dist-bundle-bun-globals.test.ts | 111 +++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/shared/dist-bundle-bun-globals.test.ts b/src/shared/dist-bundle-bun-globals.test.ts index 4d8b7a1d8..08cfc97a9 100644 --- a/src/shared/dist-bundle-bun-globals.test.ts +++ b/src/shared/dist-bundle-bun-globals.test.ts @@ -1,9 +1,58 @@ +/// + import { existsSync } from "node:fs" import { describe, expect, test } from "bun:test" const DIST_INDEX = "dist/index.js" const GLOBAL_BUN_DESTRUCTURE = /^\s*(?:var|let|const)\s*\{[^}]*\}\s*=\s*globalThis\.Bun/gm const TOP_LEVEL_REQUIRE_CALL = "__require(" +const RAW_BUN_API_CALL = /(? 120 ? `${content.slice(0, 117)}...` : content + + return `${lineNumber}: ${truncated}` +} describe("dist bundle Bun globals", () => { test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned #then no globalThis.Bun destructures remain", async () => { @@ -62,4 +111,66 @@ describe("dist bundle Bun globals", () => { stderr: "", }) }) + + test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned for raw Bun runtime APIs #then no unshimmed Bun API calls remain", async () => { + expect(hasRawBunApiCall("Bun.file('dist/index.js')")).toBe(true) + expect(hasRawBunApiCall("runtime.Bun.file('dist/index.js')")).toBe(false) + expect(hasRawBunApiCall(".Bun.file('dist/index.js')")).toBe(false) + expect(hasRawBunApiCall("$Bun.file('dist/index.js')")).toBe(false) + expect(hasRawBunApiCall("Bun.spawnSync.options")).toBe(true) + expect(hasRawBunApiCall("Bun.readableStreamToText(stream)")).toBe(true) + + const dist = await Bun.file(DIST_INDEX).text() + const offending: string[] = [] + let insideJSDoc = false + + for (const [index, line] of dist.split("\n").entries()) { + const trimmed = line.trimStart() + + if (insideJSDoc || trimmed.startsWith("/**")) { + insideJSDoc = !trimmed.includes("*/") + continue + } + + if (line.includes("runtime.Bun") || line.includes("globalThis.Bun") || line.includes("typeof Bun")) { + continue + } + + RAW_BUN_API_CALL.lastIndex = 0 + const rawMatch = [...line.matchAll(RAW_BUN_API_CALL)].find( + (match) => match.index !== undefined && !isInsideStringLiteral(line, match.index), + ) + + if (rawMatch) { + offending.push(formatOffendingLine(index + 1, line)) + } + } + + expect( + offending, + `Expected zero raw Bun API calls in dist/index.js but found ${offending.length}:\n${offending.join("\n")}`, + ).toEqual([]) + }) + + test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when imported and inspected under node --input-type=module #then stderr has no Bun reference errors", async () => { + const node = Bun.which("node") + if (!node) return + + const proc = Bun.spawn({ + cmd: [node, "--input-type=module", "-e", NODE_EXPORT_SMOKE_SCRIPT], + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }) + + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + const exitCode = await proc.exited + const stderrLower = stderr.toLowerCase() + + expect(exitCode, stderr.trim()).toBe(0) + expect(stdout).toContain("SMOKE_OK:") + expect(stderrLower).not.toContain("referenceerror") + expect(stderr).not.toContain("Bun is not defined") + }) }) From 100819f0bc9035cccd392a19a045ccd37bb9cf93 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH 8/9] ci: build plugin before running tests The dist-bundle regression tests in src/shared/dist-bundle-bun-globals.test.ts are guarded by `test.skipIf(!existsSync("dist/index.js"))` and dist/ is gitignored, so they silently skipped in CI which ran tests before the build step. Adding the build step earlier ensures the regression guard runs and a future raw `Bun.*` leak in the bundle fails CI. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ea728771..693240673 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: env: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" + - name: Build plugin + run: bun run build + - name: Run tests run: bun run script/run-ci-tests.ts From 8e07ef642ece552f16f6d8ee6becbb2a6b7d89e1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:54:24 +0900 Subject: [PATCH 9/9] fix(mcp-oauth): use fixed localhost base for callback URL parsing Cubic AI reviewer flagged the use of the untrusted Host header as the URL base in startCallbackServer. The server only binds to 127.0.0.1, so hardcoding "http://127.0.0.1" as the URL base is robust against malformed or manipulated Host values and matches upstream behavior prior to the node:http refactor. --- src/features/mcp-oauth/callback-server.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/mcp-oauth/callback-server.ts b/src/features/mcp-oauth/callback-server.ts index 64ff35a19..0d4b3410b 100644 --- a/src/features/mcp-oauth/callback-server.ts +++ b/src/features/mcp-oauth/callback-server.ts @@ -57,8 +57,7 @@ export async function startCallbackServer(startPort: number = DEFAULT_PORT): Pro }, TIMEOUT_MS) const server = createServer((request: IncomingMessage, response: ServerResponse) => { - const host = request.headers.host ?? "127.0.0.1" - const url = new URL(request.url ?? "/", `http://${host}`) + const url = new URL(request.url ?? "/", "http://127.0.0.1") if (url.pathname !== "/oauth/callback") { response.statusCode = 404