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 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 { 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/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/mcp-oauth/callback-server.ts b/src/features/mcp-oauth/callback-server.ts index 48dcb1729..0d4b3410b 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,73 @@ 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 url = new URL(request.url ?? "/", "http://127.0.0.1") - 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() }, } } 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/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/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 | 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/hashline-edit-diff-enhancer/hook.ts b/src/hooks/hashline-edit-diff-enhancer/hook.ts index 300a69887..ef042149c 100644 --- a/src/hooks/hashline-edit-diff-enhancer/hook.ts +++ b/src/hooks/hashline-edit-diff-enhancer/hook.ts @@ -1,4 +1,5 @@ import { log } from "../../shared" +import { bunFile } from "../../shared/bun-file-shim" import { generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils" interface HashlineEditDiffEnhancerConfig { @@ -38,7 +39,7 @@ function extractFilePath(args: Record): 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/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-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/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/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/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 +} 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") + }) }) 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 3e981f974..dd637ce77 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" 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( 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